diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index 616db9f..a51da1c 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -10,7 +10,8 @@ * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections * additionally pass the catalog gate (validateChatSettings / - * validateModelsSettings — unknown provider/model = 400). Thinking + * validateModelsSettings — unknown provider/model = 400, except unchanged + * model-slot selections may remain saved while unavailable). Thinking * levels never reject a write (owner, interlude 2026-09-22): a pinned * level CLAMPS to the named model's nearest supported level and the * clamped value is stored — model swaps just work, same as the @@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st if (!parsed.ok) { return Response.json({ error: parsed.error }, { status: 400 }); } + // Compare against the same record that this whole-section write replaces. + const record = readSettingsRecord() ?? { version: 1 }; let agentHealed: AgentSettings | undefined; // Section-specific cross-validation (against the catalog / the prompt @@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); } } - // Model slots wave (2026-09-20): the models section gets the same - // catalog gate the chat section gets — a slot must name a real - // provider+model and a thinking level it supports. + // New/replaced slot models pass the catalog gate. Unchanged saved model + // references may survive disconnect, including thinking-preference edits, + // so each unavailable slot can be repaired independently of the others. if (section === "models") { - const verdict = validateModelsSettings(parsed.settings as ModelsSettings); + const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models); if (!verdict.ok) { return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); } @@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st // Read-modify-write ONE section of the record; everything else rides // along untouched (other sections, unknown keys — sanitize preserves). - const record = readSettingsRecord() ?? { version: 1 }; const next = { ...record, [section]: sectionOut }; writeSettingsRecord(next); return Response.json({ settings: next }); diff --git a/components/chat.tsx b/components/chat.tsx index 203a09e..20ccc55 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, @@ -26,6 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; +import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -79,8 +80,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 +554,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 +581,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 +603,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; @@ -1152,6 +1141,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 }); @@ -1616,6 +1613,7 @@ export function Chat({ compactionRunning={compacting} onSubmitThinking={submitThinking} chatId={chatId} + onModelChange={setSelectedModel} plan={hasPlan ? : undefined} onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} @@ -1719,7 +1717,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. @@ -1854,6 +1851,7 @@ function DockOverlay({ composer, onSubmitAnswer, chatId, + onModelChange, onReserve, plan, onOpenPlan, @@ -1890,6 +1888,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; @@ -1984,6 +1983,17 @@ function DockOverlay({ const revealedRef = useRef(revealed); revealedRef.current = revealed; + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(null); + const mode = pending + ? `question:${pending.toolCallId}` + : practice + ? `practice:${practice.toolCallId}` + : "chat"; + // The plan marginalia's flank width (owner, plan-placement round): from // the DOCK container's right edge to the block's right edge — the bare // diagram's canvas. Tracked LIVE by a ResizeObserver on the block @@ -2022,28 +2032,33 @@ function DockOverlay({ return () => ro.disconnect(); }, []); + // The reserve grows immediately, but never shrinks while a question or + // practice problem owns the dock: shrinking flow space could pin a nearby + // learner back to the bottom. Composer mode may give space back. + const reserveRef = useRef(0); + const offer = useCallback((blockH: number) => { + const next = blockH + GRADIENT_H; + if (next <= reserveRef.current + 0.5 && pendingRef.current) return; + // Stamp growth at the source, including child-only field resizes, so + // stick-to-bottom's catch-up is not mistaken for the learner leaving. + if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now(); + reserveRef.current = next; + onReserve(next); + }, [onReserve]); + // The overlay must not paint over the scroller's scrollbar (owner, scroll // round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // stable both-edges), so inset the overlay's right edge by the gutter — // zero on macOS overlay scrollbars, the real width on classic platforms. - // The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round, - // 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems - // off absurdly short — the cap is now the AVAILABLE COLUMN (the - // scroller already starts below the top bar) minus a 2rem breathing - // gap, carried as --dock-max-h for the docks' scroll areas. + // Budget the whole solid dock, leaving only its question area flexible. + // The hook measures natural, visible content before the morph/reserve + // effect below. Short windows scroll the reading/question/answer body; + // the stats/model toolbar remains outside that overflow viewport. const [sbInset, setSbInset] = useState(0); - const [dockMaxH, setDockMaxH] = useState(null); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const measure = () => { - setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); - setDockMaxH(Math.max(el.clientHeight - 32, 160)); - }; - measure(); - window.addEventListener("resize", measure); - return () => window.removeEventListener("resize", measure); - }, [scrollRef]); + useDockHeight({ + scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer, + }); // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a // live turn the transcript content grows and stick-to-bottom chases it; @@ -2079,48 +2094,11 @@ function DockOverlay({ const root = blockRef.current; const el = scrollRef.current; if (!root || !el) return; - const canScroll = (n: HTMLElement, dir: number) => { - if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable - return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5; - }; - const onWheel = (e: WheelEvent) => { - if (e.deltaY === 0) return; - for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) { - if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native - } - e.preventDefault(); - el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1); - }; + const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el); root.addEventListener("wheel", onWheel, { passive: false }); return () => root.removeEventListener("wheel", onWheel); }, [scrollRef]); - // ── The reserve: the spacer's height, reported upward ── - // Grows immediately (typing, new variants). NEVER drops while a question - // is open — a shrinking reserved tail is scroll geometry and the browser - // clamp would pin a nearby learner to the bottom (the old oscillation). - // In chat mode drops apply (the composer's typing shrink is today's - // harmless in-flow behavior; nothing auto-expands there). - const reserveRef = useRef(0); - const blockRef = useRef(null); - const offer = (blockH: number) => { - const next = blockH + GRADIENT_H; - if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity - // Reserve GROWTH is the phantom-scroll SOURCE (flicker round, - // 2026-09-26): the spacer's height lands in this commit, the smooth - // catch-up lags it by the growth amount, and the position machine - // used to read that lag as the learner leaving — form → bar → form - // (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the - // mute right where the spacer grows: it covers the scroll events of - // THIS growth no matter how long after the field stretched (the - // field grows on its own render; the offer lands on the NEXT - // DockOverlay render — ~1s apart live). Shrinks never stamp (the - // fold's own give-back must not self-mute the re-expand). - if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now(); - reserveRef.current = next; - onReserve(next); - }; - // Explicit expand (bar click): the smooth scroll to the bottom takes // hundreds of ms, during which the position is still legitimately inside // the collapse zone — the machine would instantly re-collapse the dock @@ -2222,11 +2200,6 @@ function DockOverlay({ if (b) offer(b.getBoundingClientRect().height); }; - const mode = pending - ? `question:${pending.toolCallId}` - : practice - ? `practice:${practice.toolCallId}` - : "chat"; // The opening variant of a session appears instantly — the fade/rise is // reserved for mid-session transitions. const initialModeRef = useRef(mode); @@ -2234,11 +2207,9 @@ function DockOverlay({ // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. - const shellRef = useRef(null); // The fold's CONTENT fade (owner round, 2026-10-05): the shell height // morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise // on the toggle — no remount, so the question form's state survives. - const contentRef = useRef(null); const prevExpandedForFadeRef = useRef(expanded); useIsoLayoutEffect(() => { if (prevExpandedForFadeRef.current === expanded) return; @@ -2286,8 +2257,10 @@ function DockOverlay({ return (
-
+
+
+
{setupCta} {/* The queued-steer indicator (owner spec 2026-10-03): quiet one-liners above the shell - right-aligned to where its corner @@ -2316,11 +2289,13 @@ function DockOverlay({ /> )}
+ {/* Include descendant margins (notably practice thinking's + bottom margin) in the natural content measurement. */}
+ ref={contentRef} + key={mode} + className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"} + > {pending ? (
+
+
{/* The stats row: beneath the composer/question shell, not flush to the screen bottom — static height, outside the morphing shell so it never joins a height animation. */} @@ -2364,6 +2341,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/dock-height.ts b/components/dock-height.ts new file mode 100644 index 0000000..dd6f2ac --- /dev/null +++ b/components/dock-height.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react"; + +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +export interface DockHeightMeasurements { + columnHeight: number; + /** Stats/toolbar and block padding, outside the scrolling viewport. */ + outsideHeight: number; + /** Everything in the viewport except the animated shell. */ + beforeShellHeight: number; + shellBorderHeight: number; + /** Natural content height, not the shell's animated/clamped height. */ + contentHeight: number; + /** Null when collapsed, in chat mode, or showing malformed input. */ + questionHeight: number | null; + questionPaddingHeight: number; +} + +export function dockHeightBudget(m: DockHeightMeasurements) { + const available = Math.max(m.columnHeight - 32, 0); + const viewportMaxHeight = Math.max(available - m.outsideHeight, 0); + const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0); + const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight; + const questionMaxHeight = m.questionHeight === null + ? null + : Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0); + // A cap smaller than the question's padding leaves no content viewport. + // Deliberately uncap the question and scroll the whole content instead; + // do not confuse a measured zero with an unmeasured CSS fallback. + const scrollWholeContent = questionMaxHeight === null + ? naturalHeight > viewportMaxHeight + : questionMaxHeight <= m.questionPaddingHeight; + return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent }; +} + +interface DockHeightElements { + column: HTMLElement; + block: HTMLElement; + viewport: HTMLElement; + body: HTMLElement; + shell: HTMLElement; + content: HTMLElement; +} + +const pixels = (value: string) => Number.parseFloat(value) || 0; + +/** Read natural geometry before writing any constraints. Cancel the shell's + * animated height out of both ancestor measurements, then substitute its + * natural content and borders. This stays valid during a WAAPI height morph + * and when the outer viewport is already capped. */ +export function syncDockHeight(elements: DockHeightElements, expanded: boolean) { + const { column, block, viewport, body, shell, content } = elements; + // QuestionDock keeps a laid-out form in an invisible h-0 wrapper; + // PracticeDock removes its form. Neither hidden form is a height input. + const question = expanded ? content.querySelector("[data-dock-scroll-area]") : null; + const height = (el: HTMLElement) => el.getBoundingClientRect().height; + const shellStyle = getComputedStyle(shell); + const questionStyle = question ? getComputedStyle(question) : null; + const budget = dockHeightBudget({ + columnHeight: column.clientHeight, + outsideHeight: Math.max(height(block) - height(viewport), 0), + beforeShellHeight: Math.max(height(body) - height(shell), 0), + shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth), + contentHeight: height(content), + questionHeight: question ? height(question) : null, + questionPaddingHeight: questionStyle + ? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom) + : 0, + }); + if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h"); + else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`); + viewport.style.maxHeight = `${budget.viewportMaxHeight}px`; + // Overflow is visible in normal layouts: keep the shell's shadow intact. + // The stats/model toolbar is a sibling, never inside this overflow box. + viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible"; + // Clamp animated starting heights too, so a previous tall variant cannot + // paint above the column while morphing into a constrained layout. + shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`; + return budget; +} + +interface DockHeightRefs { + scrollRef: RefObject; + blockRef: RefObject; + viewportRef: RefObject; + bodyRef: RefObject; + shellRef: RefObject; + contentRef: RefObject; +} + +export function useDockHeight({ + scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled, mode, onScrollbarInset, onHeight, +}: DockHeightRefs & { + expanded: boolean; + enabled: boolean; + /** Reconnect observers when the keyed content node/scroll area changes. */ + mode: string; + onScrollbarInset: (inset: number) => void; + /** Settled visible block height, fed through the existing reserve policy. */ + onHeight: (height: number) => void; +}) { + const measure = useCallback(() => { + const column = scrollRef.current; + const block = blockRef.current; + const viewport = viewportRef.current; + const body = bodyRef.current; + const shell = shellRef.current; + const content = contentRef.current; + if (!column || !block || !viewport || !body || !shell || !content) return; + onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2)); + if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded); + else { + block.style.removeProperty("--dock-max-h"); + viewport.style.removeProperty("max-height"); + viewport.style.removeProperty("overflow-y"); + shell.style.removeProperty("max-height"); + } + // ResizeObserver runs even when only a child field changed and React + // did not rerender DockOverlay. Keep its transcript reserve current, + // but never report a mid-morph height. + if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height); + }, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]); + + // Imperative constraints land before the overlay's later layout effect + // measures its morph target/reserve, and before paint (including reveal). + useIsoLayoutEffect(measure); + useIsoLayoutEffect(() => { + const observer = new ResizeObserver(measure); + for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) { + if (el) observer.observe(el); + } + const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null; + if (question) observer.observe(question); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]); +} + +/** Overlay and transcript are siblings. Keep native scrolling in an actual + * scroll container (including the short-window viewport), then forward only + * at its edge. overflow:hidden/visible boxes cannot consume the wheel. */ +export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) { + if (event.deltaY === 0) return; + for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) { + if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue; + if (n.scrollHeight - n.clientHeight < 2) continue; + if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return; + } + event.preventDefault(); + transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1); +} 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/model-catalog.ts b/components/model-catalog.ts new file mode 100644 index 0000000..6a97320 --- /dev/null +++ b/components/model-catalog.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ModelCatalogResponse } from "@/src/core"; +import { onSettingsSaved } from "@/components/settings-signal"; + +// Shared stale-while-revalidate catalog. Metadata refreshes retain the last +// good snapshot; connection changes clear selectable models immediately. +let catalogCache: ModelCatalogResponse | undefined; +let catalogError = false; +let catalogInflight: Promise | undefined; +let catalogGeneration = 0; +let stopCatalogSignals: (() => void) | undefined; +const catalogReaders = new Set<() => void>(); +const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; + +function fetchCatalog(revalidate = false): void { + if (revalidate) { catalogGeneration++; catalogInflight = undefined; } + if (catalogInflight) return; + const generation = catalogGeneration; + catalogError = false; + publishCatalog(); + catalogInflight = fetch("/api/models") + .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) + .then((c) => { + if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); } + }) + .catch(() => { + if (generation === catalogGeneration) { catalogError = true; publishCatalog(); } + }) + .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); +} + +export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } { + const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError })); + useEffect(() => { + const first = catalogReaders.size === 0; + const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError }); + catalogReaders.add(read); + if (first) { + stopCatalogSignals = onSettingsSaved((section) => { + if (section === "keys") { + catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; + fetchCatalog(); + } else if (section === "models") fetchCatalog(true); + }); + fetchCatalog(true); + } else fetchCatalog(); + read(); + return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; + }, []); + const refresh = useCallback(() => fetchCatalog(true), []); + return { ...snapshot, refresh }; +} diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 1ae092d..0b8e46d 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; -import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { useModelCatalog } from "@/components/model-catalog"; +export { useModelCatalog } from "@/components/model-catalog"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; -/** GET /api/models is cached per page load for instant menu opens, but - * the federation is LIVE per call — keys saved mid-session widen the - * catalog immediately server-side. ModelControl therefore re-fetches on - * every model-menu open (stale-while-revalidate: the cached list shows - * instantly, the fresh one swaps in while the menu is open). */ -let catalogCache: ModelCatalogResponse | undefined; -let catalogInflight: Promise | undefined; -let catalogGeneration = 0; -let stopCatalogSignals: (() => void) | undefined; -const catalogReaders = new Set<() => void>(); -const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; -function fetchCatalog(revalidate = false): void { - if (revalidate) { catalogGeneration++; catalogInflight = undefined; } - if (catalogInflight) return; - const generation = catalogGeneration; - catalogInflight = fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) - .then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } }) - .catch(() => { /* existing unavailable/usage-only posture, no retry timer */ }) - .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); -} -export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { - const [catalog, setCatalog] = useState(catalogCache); - useEffect(() => { - const first = catalogReaders.size === 0; - const read = () => setCatalog(catalogCache); - catalogReaders.add(read); - if (first) { - stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { - catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; - publishCatalog(); fetchCatalog(); - } else if (section === "models") fetchCatalog(true); - }); - fetchCatalog(true); - } else fetchCatalog(); - read(); - return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; - }, []); - const refresh = useCallback(() => fetchCatalog(true), []); - return { catalog, refresh }; -} - /** provider:id → contextWindow over the cached catalog — the client-side * twin of the server's catalogContextLimits join (context-meter * amendment, owner 2026-09-23: pi's live catalog is the ONLY limit @@ -84,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); @@ -99,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 */ }); @@ -124,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; @@ -236,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."); @@ -315,10 +278,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod return (
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/practice-dock.tsx b/components/practice-dock.tsx index 4c53b59..3ade8f4 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -105,7 +105,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi cards' label row — owner round, 2026-10-05) + the statement, markdown + LaTeX rendered, capped by the measured column (--dock-max-h; the old fixed 40vh cut long problems short). */} -
+
Problem
{input.statement} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 08f5d23..56b3298 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -564,7 +564,7 @@ function QuestionDockForm({ {/* Question area — scrolls internally if very tall; the bottom row stays put. Capped by the measured column (--dock-max-h; the old fixed 55dvh cut long questions short — owner round, 2026-10-05). */} -
+
{toolName === "probe" ? "Probe" diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index bd7c810..7c79b64 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; +import { useModelCatalog } from "@/components/model-catalog"; +import { Hint } from "@/components/ui/hint"; import type React from "react"; import { COMPACTION_THRESHOLD_FLOOR, @@ -63,7 +65,6 @@ import { type AgentSettings, type CompactionSettings, type GraphSettings, - type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, } from "@/src/core"; @@ -101,15 +102,6 @@ interface SegmentInfo { shared: boolean; } -/** The GET /api/models response fields this section consumes. */ -interface ModelsBody { - models: ModelCatalogEntry[]; - /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no - * factory floor) — machinery only (the thinking list fallback); the - * rows' displays read the record override, never a phantom pin. */ - slots: Record; -} - /** Compose: one tool toggled, the rest of the section carried verbatim. * Pure — exported for tests. */ export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { @@ -234,8 +226,10 @@ function MenuTrigger({ onClick, widthClass, disabled, + hint, }: { value: string; + hint?: string; /** What the trigger shows when there is no override ("Default"). */ emptyLabel: string; ariaLabel: string; @@ -250,7 +244,7 @@ function MenuTrigger({ /** The no-key search state grays the whole row; the trigger follows. */ disabled?: boolean; }) { - return ( + const button = ( ); + return hint ? {button} : button; } /* The section's dropdowns all mount the shared MenuShell (fix wave chunk @@ -303,10 +298,9 @@ export function AgentSection({ /** Writes the whole compaction section (replace-PUT discipline). */ onCompactionChange: (next: CompactionSettings) => void; }) { - const [catalog, setCatalog] = useState(null); - /** The three tiers' resolved pins — GET /api/models `slots`. The rows' - * Default hints and thinking lists resolve from it. */ - const [slots, setSlots] = useState(null); + const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog(); + const catalog = body?.models; + const slots = body?.slots; /** The live search state (/api/search) — the row's reason line. */ const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null); /** Which search engines have a key (/api/keys) — the no-keys-anywhere @@ -341,28 +335,9 @@ export function AgentSection({ .catch(() => {}); }, []); - // The catalog (federated: keyed providers only), the resolved slots, - // the prompt segments, and the search engines' key presence — one - // fetch each per section mount. The models read ALSO re-runs on the - // cross-surface signal (fix wave: another surface's write re-reads - // into the slot rows live). - const modelsRequest = useRef(0); - const modelsMounted = useRef(false); - const readModels = useCallback((connectionChanged = false) => { - const request = ++modelsRequest.current; - if (connectionChanged) setCatalog(null); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((body: ModelsBody) => { - if (!modelsMounted.current || request !== modelsRequest.current) return; - setCatalog(body.models); - setSlots(body.slots); - }) - .catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); }); - }, []); - + // The shared catalog seeds the first frame and refreshes per mount and + // keys/models signal. Settings props still own saved selections/levels. useSettingsSaved((section) => { - if (section === "models" || section === "keys") readModels(section === "keys"); // THE state-echo root cause: the prompt switches write through the // agent section, but this view (the toggles' truth) never re-read — // the settings-saved law (every reader re-reads) was missing this @@ -371,8 +346,6 @@ export function AgentSection({ }); useEffect(() => { - modelsMounted.current = true; - readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) @@ -388,7 +361,6 @@ export function AgentSection({ setEngineKeys(present); }) .catch(() => {}); - return () => { modelsMounted.current = false; modelsRequest.current++; }; }, []); // The reason line re-reads /api/search AFTER an engine write lands — @@ -615,6 +587,7 @@ export function AgentSection({ return ( <> + {catalogFailed &&

Model catalog could not be loaded. Check the connection and retry.

} {MODEL_SLOTS.map((slot) => { const override = models?.[slot]; const modelId = override?.model?.id ?? ""; @@ -652,6 +625,7 @@ export function AgentSection({ {slot !== "strong" && <>} p.envVar === id); + if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)"; + return matches?.[0]?.label; +} + /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an * environment-owned value — the row itself is read-only then). Pure. */ export function keyStatusLine(status: AuthKeyStatus): string { @@ -417,7 +425,7 @@ export function KeysSection({ {providerRows.map((status) => ( p.envVar === status.id)?.label ?? status.info?.label ?? status.id} + label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id} docsUrl={status.info?.docsUrl} status={status} expanded={openRow === status.id} 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 461e507..053f3eb 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -13,7 +13,8 @@ * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a * key because there are no models to pick then"): - * Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen + * Unknown catalog — neutral loading (or error/Retry), never a key screen. + * Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen * (zero-keys wave, owner 2026-09-25): provider picker + one key * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — * saving dispatches the "keys" signal, the fetch effect re-runs on @@ -23,6 +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 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). * @@ -41,6 +45,8 @@ import { prettyModel } from "@/components/usage-format"; 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"; @@ -73,27 +79,26 @@ interface ModelsBody { * (owner's dependency ordering). Exported for tests. */ export function setupStage( providers: { id: string; keyPresent: boolean }[] | null, -): "keys" | "pickers" { - if (providers === null) return "keys"; // fetch failed → the honest fallback +): "loading" | "keys" | "pickers" { + if (providers === null) return "loading"; return providers.some((p) => p.keyPresent) ? "pickers" : "keys"; } -/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its - * pick — matched in the FEDERATED catalog (a lapsed key's pin does not - * prefill); unpinned tiers stay empty and carry the "needed" mark. - * First-run = nothing pinned → all three empty + marked. Exported pure - * for tests. */ +type ModelSelection = Pick; + +/** Saved identity is independent of selectable metadata. A disconnected + * pin still prefills; only the menu is restricted to the live catalog. */ export function prefillFromSlots( models: ModelCatalogEntry[], slots: Record | undefined, -): Partial> { - const pre: Partial> = {}; +): Partial> { + const pre: Partial> = {}; for (const slot of SLOT_ORDER) { const pin = slots?.[slot]; const entry = pin?.model ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id) : undefined; - if (entry) pre[slot] = entry; + if (pin?.model) pre[slot] = entry ?? pin.model; } return pre; } @@ -217,24 +222,38 @@ export function SetupKeysBody({ ); } -export function SetupDialog({ - open, - onClose, - /** Saved: the models section landed; the caller marks configured (the - * draft stays put — save never sends). */ - onDone, -}: { +function prefillLevels(body: ModelsBody | undefined): Partial> { + const levels: Partial> = {}; + for (const slot of SLOT_ORDER) { + const pin = body?.slots?.[slot]; + const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id); + if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel; + else if (entry) levels[slot] = prefillLevelFromPin(entry, pin); + } + return levels; +} + +interface SetupDialogProps { open: boolean; onClose: () => void; + /** Saving never sends the caller's draft. */ onDone: (models: ModelsSettings) => void; -}) { +} + +export function SetupDialog(props: SetupDialogProps) { + // An open owns its draft. Mount-time cache seeding avoids a key-screen + // first frame, and reopening cannot reuse a previous open's edits. + return props.open ? : null; +} + +function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const backdropDismiss = useBackdropDismiss(onClose); - const [body, setBody] = useState(null); - const [failed, setFailed] = useState(false); - const [picked, setPicked] = useState>>({}); - /** Per-slot thinking level (chunk 2): populated from the ordering on - * every fresh model pick, editable — an explicit pick always wins. */ - const [levels, setLevels] = useState>>({}); + const { catalog, failed, refresh } = useModelCatalog(); + 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 [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); @@ -249,64 +268,65 @@ export function SetupDialog({ const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); // Stage 1's provider-picker trigger wrapper — the shared shell's anchor. - // Declared with the other hooks: the early `if (!open) return null` sits - // below them all. (Stage 2's slot menus anchor by anchorTestid instead — - // see the slot MenuShell below for why a ref cannot work there.) + // Stage 2's slot menus anchor by anchorTestid instead — see the slot + // MenuShell below for why a freshly attached ref cannot work there. const keyTriggerRef = useRef(null); // 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); }); - // Fresh /api/models per open AND per keys change — a key saved a - // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { - if (!open) return; let cancelled = false; - setBody(null); - setPicked({}); - setLevels({}); - setError(null); fetch("/api/keys") .then((r) => r.json()) .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) .catch(() => { if (!cancelled) setKeyProviders([]); }); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((b: ModelsBody) => { - if (cancelled) return; - setBody(b); - setFailed(false); - // Prefill (owner, 2026-09-22): each pinned tier opens with its - // pick (matched in the FEDERATED catalog — a lapsed key's pin - // does not prefill); unpinned tiers stay empty and carry the - // "needed" mark. First-run = nothing pinned → all three marked. - const pre = prefillFromSlots(b.models, b.slots); - setPicked(pre); - // Levels prefill with the pins (chunk 2): stored level if still - // supported, else the ordering's level for the pinned model. - const preLevels: Partial> = {}; - for (const slot of SLOT_ORDER) { - const entry = pre[slot]; - if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]); - } - setLevels(preLevels); - }) - .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - }, [open, keysTick]); + }, [keysTick]); + + useEffect(() => { + if (!catalog) return; + const pre = prefillFromSlots(catalog.models, catalog.slots); + const preLevels = prefillLevels(catalog); + // Fresh metadata is always used, but a response cannot reset an + // edited slot, including a level chosen while the request was pending. + setPicked((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (pre[slot]) next[slot] = pre[slot]; else delete next[slot]; + } + return next; + }); + setLevels((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot]; + } + return next; + }); + }, [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 // (and any click outside it); only a bare Esc closes the dialog. useEffect(() => { - if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at @@ -341,17 +361,18 @@ export function SetupDialog({ document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; - }, [open, onClose, openMenu, keyMenuOpen]); + }, [onClose, openMenu, keyMenuOpen]); - if (!open) return 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 stage = failed ? "keys" : setupStage(body ? body.providers : 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); @@ -398,6 +419,20 @@ export function SetupDialog({ onDone(models); } + const keysBody = setKeyMenuOpen((v) => !v)} + onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} + onValue={setKeyValue} + onSave={() => void saveKey()} + />; + return createPortal(
- {stage === "keys" ? ( -
- {failed ? ( -

- Model catalog could not be loaded. Check the connection and retry. -

- ) : ( - setKeyMenuOpen((v) => !v)} - onPick={(p) => { - setKeyProvider(p); - setKeyMenuOpen(false); - setKeyError(null); - }} - onValue={setKeyValue} - onSave={() => void saveKey()} - /> - )} -
+ {failed && ( +

+ Model catalog could not be loaded. Check the connection and retry. +

+ )} + {stage === "loading" ? ( + !failed &&

Loading models…

+ ) : stage === "keys" ? ( +
{keysBody}
) : (

@@ -461,14 +479,16 @@ export function SetupDialog({ {SLOT_ORDER.map((slot) => { const entry = picked[slot]; const level = levels[slot]; - const levelsForEntry = entry?.supportedLevels ?? []; - const catalog = - slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; + const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider); + // Unavailable metadata cannot erase or clamp a saved preference. + const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []); + const available = catalog?.models ?? (failed ? [] : undefined); + const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available; return (

{SLOT_META[slot].label} - {!body?.slots?.[slot]?.model && !picked[slot] && ( + {!catalog?.slots?.[slot]?.model && !picked[slot] && ( }
+ + {/* Anchor by TESTID, not a ref (the settings-section pattern): React attaches a host element's ref AFTER its descendants' layout effects run, so a ref freshly attached in this same @@ -512,10 +534,11 @@ export function SetupDialog({ menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl" > { + editedSlots.current.add(slot); setPicked((prev) => ({ ...prev, [slot]: m })); // Auto-populate (chunk 2, owner: "populate // everywhere"): no explicit level → the @@ -527,7 +550,7 @@ export function SetupDialog({ })); setOpenMenu(null); }} - footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined} + footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined} /> )} @@ -563,6 +586,7 @@ export function SetupDialog({ levels={levelsForEntry.map(String)} effectiveLevel={level} onPick={(lv) => { + editedSlots.current.add(slot); setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel })); setOpenMenu(null); }} diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 9491045..6c1c05d 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; @@ -34,6 +34,7 @@ export function StatsBar({ dictationSlot, chatId, leadingSlot, + onModelChange, }: { turns: TurnUsage[]; contextTokens?: number | null; @@ -43,6 +44,8 @@ export function StatsBar({ * record; absent on the draft surfaces, where it binds the strong * slot (the new-session default). */ chatId?: string; + /** The same effective identity the visible model control owns. */ + onModelChange?: (model: string | undefined) => 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/components/usage-format.ts b/components/usage-format.ts index 3a400cd..2bd7d60 100644 --- a/components/usage-format.ts +++ b/components/usage-format.ts @@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin return { provider: id.slice(0, colon), id: id.slice(colon + 1) }; } -/** - * "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the - * name KEPT EXACTLY as its provider writes it (dashes stay, no - * auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit - * segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped. - * Quiet-chip friendly. - * - * With `qualified` (usage stamps are "provider:id", model-slots wave): - * the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" — - * because with federation the same model via two providers is genuinely - * different bookkeeping. Bare catalog ids are unchanged. - */ +/** Model-only labels omit the vendor path, never versions, dates or variants. + * Only usage callers pass `qualified`: a bare catalog id's colon belongs to + * the model (e.g. :free), not a provider. Usage retains its provider suffix. */ export function prettyModel(id: string, qualified = false): string { - const { provider, id: bare } = splitQualified(id); + const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id }; const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; - const parts = short.split("-"); - const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; - // No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05): - // model names render exactly as their providers write them. - const pretty = trimmed.join("-"); - return qualified && provider ? `${pretty} · ${provider}` : pretty; + return provider ? `${short} · ${provider}` : short; } \ No newline at end of file diff --git a/docs/compaction-accounting.md b/docs/compaction-accounting.md index f3b20d6..b2abcdc 100644 --- a/docs/compaction-accounting.md +++ b/docs/compaction-accounting.md @@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization lifecycle. It does not adopt Pi's coding summary or separate split-turn summary calls. A cut that drops no active history does not launch a summarizer. +When the active cutoff is applied, conversation requests carry the latest summary +once, followed by the retained transcript without older compaction markers. When +a subsequent compaction supplies that prior summary separately, it includes it +once, plus the newly superseded transcript without summary markers. All markers +and original messages remain stored. The existing full-history fallback for an +unresolved cutoff is unchanged, including its summary text. + +A provider response stopped at its output limit (`length`) is an incomplete +summary, not a successful checkpoint. Compaction fails without changing the +existing history or prior summary; a later attempt can retry normally. + ## Conversation meter The numerator is the latest qualifying completed conversation response's native diff --git a/src/core/compaction.ts b/src/core/compaction.ts index 8e3f768..4ca0fab 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails) }; } -/** The ACTIVE compaction marker: the newest one in the record (chained - * compactions — the last one rules; older markers fall inside the - * dropped span and never render). */ +/** The ACTIVE compaction marker: the newest one with a retained-message + * id. Its summary supersedes older markers even when their stored + * positions remain inside its retained tail. */ export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 871e462..60f51ce 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -563,8 +563,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker // (the newest — chained compactions rule last) names the first kept // message; everything before it is replaced by the marker's content - // (header + summary) rendered ONCE, up top. The marker itself is then - // skipped in place — it must never render twice. A cut naming a + // (header + summary) rendered ONCE, up top. All compaction markers + // in the retained tail are then skipped, including older summaries + // whose stored positions still lie after the cut. A cut naming a // missing id degrades honestly (everything renders — never throw). const lhMessages = messages as readonly LhMessage[]; const active = activeCompactionOf(lhMessages); @@ -605,11 +606,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // UI-only markers: plan blocks, and problem dismissals (the // synthesized conclude pair carries the model's face). if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue; - // The ACTIVE compaction marker already rendered as the head — - // never twice. (Stale markers before the cut are unreachable; a - // marker whose cut could not resolve has no head and renders as - // its user text — honest degradation.) - if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue; + // The head supersedes every compaction marker, not only its own + // stored position. Without a resolved head, preserve the existing + // full-history fallback, including the summary text. + if (marker.customType === "compaction" && head !== null) continue; out.push({ role: "user", content: [{ type: "text", text: marker.content }], diff --git a/src/server/compaction.ts b/src/server/compaction.ts index cc62aa6..865f9ca 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; - const { text } = await runSubagent([], { + const { text, newMessages } = await runSubagent([], { // The engine override is always present (the chat's own conversation // engine — owner ruling), so the role label never resolves an // engine; "researcher" is the least-wrong existing ModelRole label @@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s usage = u; }, }); + // A provider-declared incomplete response is not a usable checkpoint, + // even when it contains nonempty text. Match Pi's compaction failure + // semantics: leave the existing history and prior summary untouched. + if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) { + throw new Error("Compaction summary hit the output limit and is incomplete"); + } return { summary: text, usage }; } @@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise { spanStart = prevKept; priorSummary = previous.content; } - const spanText = serializeForSummary(messages.slice(spanStart, cut)); + const span = messages.slice(spanStart, cut); + // The separately supplied prior summary replaces all summary markers + // in this span. Without a resolved prior boundary, retain the existing + // legacy fallback rather than silently discarding summary-only context. + const summaryMessages = priorSummary + ? span.filter((m) => m.role !== "custom" || m.customType !== "compaction") + : span; + const spanText = serializeForSummary(summaryMessages); const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText; const prompt = buildCompactionPrompt({ diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 0aa0b2a..11fbe0f 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -245,10 +245,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation return { ok: true }; } -export function validateModelsSettings(settings: ModelsSettings): SettingsValidation { +/** Validate introduced/replaced model selections, not unchanged saved pins. + * A disconnected subscription loses its available catalog, but its stored + * selections must not prevent repairing another slot or editing thinking. + * Only the SAME slot's provider+id can pass through; runtime admission stays + * separate. Callers without a stored section retain strict catalog checking. */ +export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation { for (const name of MODEL_SLOTS) { const slot = settings[name]; if (!slot) continue; + const previous = stored?.[name]?.model; + if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue; const verdict = validateSlotSpec(name, slot); if (!verdict.ok) return verdict; } @@ -280,9 +287,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin // runtime — and the clamped value is what lands in the record. The // server is the truth: the record shows the clamped level after write. -/** Clamp one level against one model (identity when the model is unknown - * or the level already supported — the existence gate rejects unknown - * models separately, so this only ever sees real ones in practice). */ +/** Clamp one level against one model. Preserve the preference when metadata + * is unavailable for an unchanged saved pin; runtime clamps after reconnect. */ function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel { const model = connectionModel(provider, id); return model ? clampThinkingLevel(model, level) : level; diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index fab7db2..7627b8f 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => { assert.deepEqual(pre, {}); }); - it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => { + it("a disconnected saved identity still prefills without selectable metadata", () => { const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null }); - assert.deepEqual(pre, {}); + assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } }); }); it("provider collisions never cross-pollute (same id, different provider)", () => { @@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", ( }); }); -describe("setupStage — unchanged by the BYOK wave", () => { +describe("setupStage — unknown is not keyless", () => { it("keys first (owner ordering), pickers when a provider is keyed", () => { - assert.equal(setupStage(null), "keys"); + assert.equal(setupStage(null), "loading"); assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys"); assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers"); }); diff --git a/tests/compaction-service.test.ts b/tests/compaction-service.test.ts index ee5b4d4..9a27a66 100644 --- a/tests/compaction-service.test.ts +++ b/tests/compaction-service.test.ts @@ -27,12 +27,14 @@ const { ensureCompacted, kickCompaction, awaitCompaction, + compactionInFlight, effectiveThresholdTokens, projectedContextTokens, } = await import("../src/server/compaction.ts"); import type { CompactionDeps } from "../src/server/compaction.ts"; +import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai"; const { subscribe } = await import("../src/core/events.ts"); -const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); +const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); import type { LhMessage } from "../src/core/index.ts"; after(() => { @@ -167,12 +169,120 @@ describe("the compaction service", () => { await ensureCompacted(deps, chatId); assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span"); assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text"); + assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once"); const record = chatStore.getChat(chatId)!; const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction"); assert.equal(markers.length, 2, "both markers persist (history is never rewritten)"); assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules"); }); + it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => { + const chat = chatStore.createChat("repeated-cycle fixture"); + chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8))); + let calls = 0; + const deps: CompactionDeps = { + ...depsOf(chat.id, 150_000), + summarize: async (prompt) => { + assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1); + if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward"); + return { + summary: `SUMMARY-${++calls}.`, + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + }; + }, + }; + for (let cycle = 1; cycle <= 10; cycle++) { + const messages = [...chatStore.getChat(chat.id)!.messages]; + if (cycle > 1) messages.push(big(`growth-${cycle}`, 8)); + // Controlled provider readings trigger compaction while the visible + // retained tail advances slowly enough to contain older markers. + messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage); + chatStore.saveMessages(chat.id, messages); + await ensureCompacted(deps, chat.id); + assert.equal(calls, cycle); + const record = chatStore.getChat(chat.id)!; + assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored"); + assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle); + const wire = JSON.stringify(toLlmMessages(record.messages)); + assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`); + assert.ok(wire.includes(`SUMMARY-${cycle}.`)); + assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings"); + } + }); + + it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => { + const chat = chatStore.createChat("summary span fixture"); + const kept = big("previously retained"); + const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" }; + const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 }; + const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")]; + chatStore.saveMessages(chat.id, messages); + let seenPrompt = ""; + await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => { + seenPrompt = prompt; + return depsOf(chat.id, 40_000).summarize!(prompt, engine); + } }, chat.id); + assert.ok(seenPrompt.includes("previously retained")); + assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span"); + assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY")); + assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1); + assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1); + assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages); + }); + + it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => { + const chat = chatStore.createChat("legacy summary fixture"); + const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" }); + const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")]; + chatStore.saveMessages(chat.id, messages); + let seenPrompt = ""; + await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => { + seenPrompt = prompt; + return depsOf(chat.id, 40_000).summarize!(prompt, engine); + } }, chat.id); + assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1); + assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff"); + assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages); + }); + + it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => { + const chatId = overThresholdChat("incomplete summary fixture"); + await ensureCompacted(depsOf(chatId, 40_000), chatId); + chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]); + const faux = createFauxCore({}); + const { summarize: _injected, ...base } = depsOf(chatId, 40_000); + const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } }; + const before = chatStore.getChat(chatId)!; + assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too"); + const events: string[] = []; + const off = subscribe((event) => { + const { chatId: id, running } = event as { chatId?: string; running?: boolean }; + if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`); + }); + // Exercise the actual zero-tool summarizer with an offline provider; + // NODE_ENV=test otherwise deliberately skips non-injected summaries. + const env: Record = process.env; + const originalNodeEnv = env.NODE_ENV; + env.NODE_ENV = "development"; + try { + faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]); + await ensureCompacted(deps, chatId); + assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint"); + assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate"); + assert.deepEqual(events, ["true", "false"]); + faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]); + await ensureCompacted(deps, chatId); + const record = chatStore.getChat(chatId)!; + assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY")); + assert.deepEqual(record.messages.slice(0, -1), before.messages); + assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY")); + } finally { + if (originalNodeEnv === undefined) delete env.NODE_ENV; + else env.NODE_ENV = originalNodeEnv; + off(); + } + }); + it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => { assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000); assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384); diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index 5139558..954ea5f 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => { assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once"); }); + it("omits nine superseded summaries inside the retained tail without changing stored history", () => { + const kept = newUserMessage("retained exchange"); + const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" }; + const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details)); + const fresh = newCompactionMarker("LATEST SUMMARY", details); + const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh]; + const original = JSON.stringify(messages); + const out = toLlmMessages(messages); + const flat = JSON.stringify(out); + assert.equal(out.length, 3, "latest summary + retained exchange + latest reply"); + assert.ok(flat.includes("LATEST SUMMARY")); + assert.ok(!flat.includes("SUPERSEDED-")); + assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1); + assert.equal(JSON.stringify(messages), original, "projection never rewrites the record"); + }); + it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => { const kept = [assistantWithCall("a1", "call_1")]; const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" }); @@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => { assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)"); }); + it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => { + const kept = newUserMessage("retained exchange"); + const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" }); + const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" }); + const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh]; + const original = JSON.stringify(messages); + const out = toLlmMessages(messages); + assert.equal(out.length, messages.length); + assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff"); + const flat = JSON.stringify(out); + assert.ok(flat.includes("OLD SUMMARY")); + assert.ok(flat.includes("LATEST SUMMARY")); + assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2); + assert.equal(JSON.stringify(messages), original); + }); + it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => { const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" }); const m1 = newUserMessage("x"); @@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => { assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize"); }); + it("preserves legacy summary text when the caller has no separately supplied prior summary", () => { + const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" }); + assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY")); + }); + it("the prompt demands the four sections in order and carries the plan tree verbatim", () => { const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" }); const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map( @@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", () const record = (): LhMessage[] => [ { id: "old", role: "user", content: "dropped history", timestamp: 1 }, { id: "kept", role: "user", content: "kept tail", timestamp: 2 }, + newCompactionMarker("SUPERSEDED SUMMARY", { + firstKeptMessageId: "kept", + tokensBefore: 98, + at: new Date(0).toISOString(), + }), newCompactionMarker("SUMMARY", { firstKeptMessageId: "kept", tokensBefore: 99, @@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", () : ((m.content as { text?: string }[])[0]?.text ?? ""); assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail")); assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history")); + assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY")); + assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1); }); it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => { diff --git a/tests/dock-height.test.tsx b/tests/dock-height.test.tsx new file mode 100644 index 0000000..e6d9d3d --- /dev/null +++ b/tests/dock-height.test.tsx @@ -0,0 +1,259 @@ +import React, { act, useLayoutEffect, useRef, useState } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { PracticeDock } from "../components/practice-dock.tsx"; + +const dom = parseHTML(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const observers: TestObserver[] = []; +class TestObserver { + elements = new Set(); + disconnected = false; + constructor(readonly callback: () => void) { observers.push(this); } + observe(el: Element) { this.elements.add(el); } + disconnect() { this.disconnected = true; this.elements.clear(); } +} +Object.assign(globalThis, { + ResizeObserver: TestObserver, + getComputedStyle: (el: HTMLElement) => ({ + borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px", + borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px", + paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px", + paddingBottom: "0px", + overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"), + }), +}); +// Import after installing window so the hook uses its browser layout effect. +const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts"); + +const normal = { + columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0, + shellBorderHeight: 2, contentHeight: 800, questionHeight: 600, + questionPaddingHeight: 12, +}; + +describe("dockHeightBudget — whole dock, not just its question", () => { + it("subtracts answer controls, toolbar, borders and breathing gap", () => { + assert.deepEqual(dockHeightBudget(normal), { + viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false, + }); + }); + it("accounts for growing answer fields and the separate reading draft", () => { + const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 }); + assert.equal(result.questionMaxHeight, 156); + assert.equal(result.shellMaxHeight, 438); + }); + it("preserves a computed zero and deliberately selects whole-content scrolling", () => { + const result = dockHeightBudget({ ...normal, columnHeight: 292 }); + assert.equal(result.questionMaxHeight, 0); + assert.equal(result.scrollWholeContent, true); + }); + it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => { + assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true); + assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false); + }); + it("never creates negative caps, even if the toolbar exceeds the column", () => { + const result = dockHeightBudget({ ...normal, columnHeight: 20 }); + assert.equal(result.viewportMaxHeight, 0); + assert.equal(result.shellMaxHeight, 0); + assert.equal(result.questionMaxHeight, 0); + assert.equal(result.scrollWholeContent, true); + }); + it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => { + assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false); + assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true); + }); +}); + +// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout. +// Live-browser checks separately exercise actual field sizing and WAAPI. +const sizes: Record = {}; +let animating = false; +dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : []; +const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 }); +const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect; +dom.window.HTMLElement.prototype.getBoundingClientRect = function () { + const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part; + if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} }; + return originalRect.call(this); +}; +Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } }); + +const ignoreHeight = () => {}; +function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: { + expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void; + onHeight?: (height: number) => void; +}) { + const scrollRef = useRef(null); + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(null); + const [inset, setInset] = useState(0); + useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight }); + // Like DockOverlay's later morph/reserve layout effect: constraints must + // already be applied in this same commit, not an after-paint correction. + useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); }); + return <>
+
+
+ {practice ? {}} onSubmit={() => {}} /> + : {}} onSubmit={() => {}} />} +
+
+
; +} + +async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps) => Promise) => Promise) { + resetSizes(); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const render = async (props = {}) => { await act(async () => root.render()); }; + try { await run(host, render); } + finally { await act(async () => root.unmount()); host.remove(); } +} +const part = (host: HTMLElement, name: string) => host.querySelector(`[data-part="${name}"]`)!; + +describe("measured question/practice DOM and observer lifecycle", () => { + it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => { + await withFixture(async (host, render) => { + await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") }); + assert.equal(part(host, "block").dataset.inset, "10"); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]'))); + }); + }); + it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => { + await withFixture(async (host, render) => { + await render(); + Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 }); + await act(async () => observers.at(-1)!.callback()); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + }); + }); + it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => { + await withFixture(async (host, render) => { + Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 }); + await render({ expanded: false }); + assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted"); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", ""); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + resetSizes(); + await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") }); + }); + }); + it("reconnects to practice's newly mounted question after collapsing and revealing", async () => { + await withFixture(async (host, render) => { + Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 }); + await render({ practice: true, expanded: false }); + const collapsedObserver = observers.at(-1)!; + assert.equal(host.querySelector('[data-dock-scroll-area]'), null); + resetSizes(); + await render({ practice: true, expanded: true }); + assert.equal(collapsedObserver.disconnected, true); + assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!)); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + }); + }); + it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => { + await withFixture(async (host, render) => { + sizes.column = 292; + await render(); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none"); + assert.equal(part(host, "viewport").style.overflowY, "auto"); + assert.equal(part(host, "viewport").style.maxHeight, "200px"); + assert.equal(part(host, "shell").style.maxHeight, "none"); + sizes.column = 700; + await act(async () => observers.at(-1)!.callback()); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + }); + }); + it("removes constraints in composer mode and disconnects observers on unmount", async () => { + let last: TestObserver; + await withFixture(async (host, render) => { + await render(); + await render({ enabled: false }); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", ""); + assert.equal(part(host, "shell").style.maxHeight || "", ""); + last = observers.at(-1)!; + }); + assert.equal(last!.disconnected, true); + }); + it("reports child-only resize heights to the reserve, but never mid-morph values", async () => { + await withFixture(async (_host, render) => { + const heights: number[] = []; + await render({ onHeight: h => heights.push(h) }); + assert.equal(heights.at(-1), 862); + animating = true; + try { + sizes.block = 900; + await act(async () => observers.at(-1)!.callback()); + assert.equal(heights.at(-1), 862, "animated block height was not reserved"); + } finally { animating = false; } + await act(async () => observers.at(-1)!.callback()); + assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render"); + }); + }); + it("reconnects to a new keyed mode even when expansion is unchanged", async () => { + await withFixture(async (host, render) => { + await render(); + const previous = observers.at(-1)!; + const oldContent = part(host, "content"); + await render({ practice: true }); + assert.equal(previous.disconnected, true); + assert.notEqual(part(host, "content"), oldContent); + assert.ok(observers.at(-1)!.elements.has(part(host, "content"))); + }); + }); + it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => { + await withFixture(async (host, render) => { + await render(); + const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"), + body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") }; + assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true)); + }); + }); +}); + +describe("wheel routing — question, short-window content, then transcript", () => { + it("keeps native wheel input in the question and then the whole-content viewport", () => { + const root = document.createElement("div"); + root.innerHTML = '
text
'; + const viewport = root.firstElementChild as HTMLElement; + const question = viewport.firstElementChild as HTMLElement; + const transcript = document.createElement("div"); transcript.scrollTop = 100; + for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; } + const wheel = (deltaY: number, deltaMode = 0) => { + let prevented = false; + forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript); + return prevented; + }; + assert.equal(wheel(-20), false, "question can scroll up"); + question.scrollTop = 0; + assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge"); + viewport.scrollTop = 0; + assert.equal(wheel(-20), true); + assert.equal(transcript.scrollTop, 80); + question.scrollTop = viewport.scrollTop = 300; + assert.equal(wheel(1, 1), true); + assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized"); + viewport.style.overflowY = "visible"; viewport.scrollTop = 50; + assert.equal(wheel(-20), false, "question still consumes upward movement"); + question.style.overflowY = "hidden"; + assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers"); + assert.equal(wheel(0), false); + }); +}); diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx new file mode 100644 index 0000000..a426ae2 --- /dev/null +++ b/tests/model-catalog-setup.test.tsx @@ -0,0 +1,298 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { SetupDialog } from "../components/setup-dialog.tsx"; +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(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0), + cancelAnimationFrame: (id: number) => clearTimeout(id), +}); +Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 }); +dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +const originalFetch = globalThis.fetch; +let root: Root | undefined; +let host: HTMLDivElement | undefined; +let requests: ReturnType>[] = []; +let writes: ModelsSettings[] = []; +let snapshot: ReturnType; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; } +const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" }; +const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; +const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] }; +function catalog(connected = true): ModelCatalogResponse { + return { + models: connected ? [a, b] : [], defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null }, + slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }, + providers: [{ ...provider, keyPresent: connected }], + }; +} +function Reader() { snapshot = useModelCatalog(); return null; } +function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return ; } +function installFetch() { + requests = []; writes = []; + globalThis.fetch = async (input, init) => { + const path = String(input); + if (path === "/api/models") { const request = deferred(); requests.push(request); return request.promise; } + if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); } + if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] }); + if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] }); + if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" }); + throw new Error(`Unexpected request ${path}`); + }; +} +async function render(children: React.ReactNode) { + if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); } + await act(async () => root!.render(children)); +} +async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); } +function element(testid: string) { const e = document.querySelector(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; } +async function click(testid: string) { await act(async () => element(testid).click()); } +function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); } +async function warm(body = catalog()) { await render(); await answer(0, body); } + +afterEach(async () => { + await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; + globalThis.fetch = originalFetch; +}); + +test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => { + installFetch(); await render(setup()); + assert.ok(element("setup-stage-loading")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await answer(0, null); + assert.ok(element("setup-catalog-error")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await click("setup-retry"); assert.ok(element("setup-stage-loading")); + const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null }; + await answer(1, empty); assert.ok(element("setup-stage-keys")); +}); + +test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => { + installFetch(); await warm(); + await render(<>{setup()}); + assert.equal(requests.length, 2); + assert.ok(element("setup-stage-pickers")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5"); + await click("setup-thinking-strong"); await click("thinking-option-medium"); + const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }; + fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] }; + await answer(1, fresh); + assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾"); + assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾"); + await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]); + await click("setup-thinking-strong"); + await act(async () => snapshot.refresh()); await answer(2, null); + assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false); + await click("setup-save"); + assert.deepEqual(writes[0], { + strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" }, + cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }, + vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, + }); +}); + +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"); + await act(async () => notifySettingsSaved("keys")); + 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.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 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 = []; + function Probe() { + const { catalog } = useModelCatalog(); + React.useLayoutEffect(() => { stages.push(document.querySelector('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]); + return null; + } + await render(<>{setup()}); + await answer(1, 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 () => { + installFetch(); await warm(); + const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }; + let changed: ModelsSettings | undefined; + await render(<> {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} />); + await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]); + assert.equal(element("slot-model-strong").textContent, "model:free▾"); + await answer(1, catalog()); + await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false)); + assert.equal(element("slot-thinking-strong").textContent, "low▾"); + 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 }); +}); + +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); + 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()); + const fresh = catalog(); fresh.models = [b]; + await answer(2, fresh); await answer(1, null); + assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); await answer(3, null); + assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false); + await answer(4, fresh); +}); + +test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => { + const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }]; + assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture"); + assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0); +}); diff --git a/tests/models-settings-repair.test.ts b/tests/models-settings-repair.test.ts new file mode 100644 index 0000000..224e08d --- /dev/null +++ b/tests/models-settings-repair.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, beforeEach, test } from "node:test"; +import type { ModelsSettings, ModelSlotName } from "../src/core/settings.ts"; +import type { NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts"; + +// Every app import follows isolation. No installed credentials, ambient keys, +// OAuth, or provider requests: subscription state comes from synthetic IPC. +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-models-repair-")); +process.env.LH_DATA_DIR = directory; +process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); +process.env.LH_IGNORE_ENV_KEYS = "1"; +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}"); +const originalSend = Object.getOwnPropertyDescriptor(process, "send"); +const originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); +const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET; +process.env.LH_DESKTOP_SERVER = "1"; +process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); +Object.defineProperty(process, "connected", { configurable: true, value: true }); + +const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); +const { refreshSubscriptionModels } = await import("../src/agent/subscription-bridge.ts"); +const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts"); +const { readSettings: readSettingsRecord, writeSettings: writeSettingsRecord } = await import("../src/store/settings.ts"); +const { PUT } = await import("../app/api/settings/[section]/route.ts"); +const { clampThinkingLevel, normalizeContext } = await import("@earendil-works/pi-ai"); +const native = piModels().getModels("github-copilot")[0]!; +const copilot = { provider: "github-copilot-subscription", id: native.id }; +const replacement = { provider: "openrouter", id: "z-ai/glm-5.3-flash" }; +const slots: ModelSlotName[] = ["strong", "cheap", "vision"]; +const initial: ModelsSettings = Object.fromEntries(slots.map((slot) => [slot, { model: copilot, thinkingLevel: "high" }])); +let connected = false, revision = 0, leases = 0; +function snapshot(): NativeSubscriptionSnapshot { + return { + statuses: [{ providerId: "github-copilot", available: true, connected, pending: false, allowBackgroundUse: true, connectionId: connected ? "fixture-connection" : null, epoch: revision, authorityRevision: revision, revision }], + models: { "github-copilot": connected ? [native] : [] }, + }; +} +Object.defineProperty(process, "send", { configurable: true, value: (message: { id: string; op: string }, callback?: () => void) => { + if (message.op !== "snapshot") { leases++; throw new Error("No credential lease expected"); } + queueMicrotask(() => (process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", id: message.id, op: message.op, ok: true, value: snapshot() })); + callback?.(); return true; +} }); +async function connection(value: boolean) { connected = value; revision++; await refreshSubscriptionModels(); } +async function put(body: unknown) { + return PUT(new Request("http://localhost/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), { params: Promise.resolve({ section: "models" }) }); +} +beforeEach(async () => { + await connection(false); + writeSettingsRecord({ version: 1, models: initial, general: { autoUpdate: false }, graph: { rendering: false } }); +}); +after(() => { + if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; + if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected; + for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret]]) { + if (value === undefined) delete process.env[key!]; else process.env[key!] = value; + } + fs.rmSync(directory, { recursive: true, force: true }); +}); + +for (const slot of slots) test(`${slot} can be replaced while the other disconnected slots remain saved`, async () => { + const response = await put({ ...initial, [slot]: { model: replacement, thinkingLevel: "xhigh" } }); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + const stored = readSettingsRecord()!; + assert.deepEqual(stored.models?.[slot], { model: replacement, thinkingLevel: "max" }); + for (const other of slots.filter((name) => name !== slot)) assert.deepEqual(stored.models?.[other], initial[other]); + assert.deepEqual(stored.general, { autoUpdate: false }); + assert.deepEqual(stored.graph, { rendering: false }); + assert.deepEqual((await response.json()).settings, stored); + assert(!modelCatalog().models.some((model) => model.provider === copilot.provider), "saving stale pins must not make them available choices"); +}); + +test("all three slots can be repaired successively without reconnecting", async () => { + for (const slot of slots) { + const response = await put({ ...readSettingsRecord()!.models, [slot]: { model: replacement } }); + assert.equal(response.status, 200); + } + assert.deepEqual(readSettingsRecord()!.models, Object.fromEntries(slots.map((slot) => [slot, { model: replacement }]))); +}); + +test("thinking preferences on unchanged disconnected models can be changed, removed, and restored", async () => { + for (const thinkingLevel of ["max", undefined, "minimal"] as const) { + const strong = { model: copilot, ...(thinkingLevel ? { thinkingLevel } : {}) }; + const response = await put({ ...initial, strong }); + assert.equal(response.status, 200); + assert.deepEqual(readSettingsRecord()!.models?.strong, strong); + } + await connection(true); + const engine = piEngineForSpec({ ...copilot, thinkingLevel: "minimal" }, false); + assert.equal(engine.level, clampThinkingLevel(native, "minimal"), "reconnected runtime clamps the saved preference"); + assert.equal((await put(readSettingsRecord()!.models)).status, 200); + assert.equal(readSettingsRecord()!.models?.strong?.thinkingLevel, engine.level, "known metadata still clamps on write"); +}); + +test("introduced or replaced unknown models still reject, without writing any section", async () => { + for (const model of [ + { provider: copilot.provider, id: "fixture-not-in-catalog" }, + { provider: "xai-subscription", id: copilot.id }, + { provider: "fixture-unknown-provider", id: copilot.id }, + { provider: replacement.provider, id: "fixture-not-in-catalog" }, + ]) { + const before = readSettingsRecord(); + assert.equal((await put({ ...initial, strong: { model } })).status, 400); + assert.deepEqual(readSettingsRecord(), before); + } + writeSettingsRecord({ version: 1, models: { strong: initial.strong } }); + assert.equal((await put({ strong: initial.strong, cheap: initial.strong })).status, 400, "a pin saved in one slot cannot authorize another slot"); + assert.equal((await put(initial)).status, 400, "missing slots cannot introduce disconnected models"); +}); + +test("a first models write cannot introduce disconnected selections", async () => { + fs.unlinkSync(path.join(directory, "settings.json")); + assert.equal((await put(initial)).status, 400); + assert.equal(readSettingsRecord(), null); +}); + +test("a replaced stale selection cannot be reintroduced without availability", async () => { + assert.equal((await put({ ...initial, strong: { model: replacement } })).status, 200); + assert.equal((await put(initial)).status, 400); + assert.deepEqual(readSettingsRecord()!.models?.strong?.model, replacement); +}); + +test("slot removal still replaces the whole section, including clearing all slots", async () => { + assert.equal((await put({ cheap: initial.cheap })).status, 200); + assert.deepEqual(readSettingsRecord()!.models, { cheap: initial.cheap }); + assert.equal((await put({})).status, 200); + assert.deepEqual(readSettingsRecord()!.models, {}); +}); + +test("unchanged model identity does not bypass body shape validation", async () => { + for (const body of [ + { ...initial, strong: { model: copilot, thinkingLevel: 7 } }, + { ...initial, strong: { thinkingLevel: "high" } }, + { ...initial, strong: { model: { provider: copilot.provider } } }, + ]) assert.equal((await put(body)).status, 400); + assert.deepEqual(readSettingsRecord()!.models, initial); +}); + +test("standalone catalog validation stays strict without a stored section", () => { + assert.equal(validateModelsSettings(initial).ok, false); + assert.equal(validateModelsSettings({ strong: { model: replacement } }).ok, true); +}); + +test("retained selections remain unavailable at runtime, with no API-key fallback or credential lease", async () => { + await connection(true); + const engine = piEngineForSpec(copilot, false); + await connection(false); + assert.equal((await put({ ...initial, cheap: { model: replacement } })).status, 200); + assert.throws(() => piEngineForSpec(copilot, false), /catalog has no model/); + let requests = 0; + const before = leases; + const key = process.env.COPILOT_GITHUB_TOKEN; + process.env.COPILOT_GITHUB_TOKEN = "fixture-must-not-be-used"; + try { + const result = await engine.streamFn(engine.model, normalizeContext({ messages: [] }), { fetch: async () => { requests++; throw new Error("Must not dispatch"); } }).result(); + assert.equal(result.stopReason, "error"); + assert.match(result.errorMessage ?? "", /No API-key fallback was used/); + assert.equal(requests, 0); + assert.equal(leases, before); + } finally { + if (key === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = key; + } +}); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 329f186..4f80fa0 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -89,9 +89,15 @@ describe("stats bar", () => { assert.match(empty, /data-testid="stats-context">\? { + it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => { assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) - assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); + assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902"); + assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free"); + assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch"); + assert.equal(prettyModel("claude-opus-5"), "claude-opus-5"); + assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5"); + assert.equal(prettyModel("model:free"), "model:free"); + assert.equal(prettyModel("model:variant:free"), "model:variant:free"); }); it("provider-qualified stamps (usage contract, model-slots wave)", () => { @@ -99,8 +105,10 @@ describe("stats bar", () => { // the same model via two providers different bookkeeping assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter"); assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai"); - // unqualified on a qualified stamp: provider silently dropped (old shape) - assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash"); + assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter"); + assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode"); + // Bare ids are never parsed as usage stamps, even with a colon. + assert.equal(prettyModel("model:free"), "model:free"); // qualified on a bare id: no suffix invented assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash"); }); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index d629349..3c38f1d 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", ( }); it("setupStage: keys before models (owner's dependency ordering)", () => { - assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback"); + assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing"); assert.equal(setupStage([]), "keys", "no providers = keys"); assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless"); assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");