From 3e9a85d60f9b6ea67c36b223a279b579c07497e5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 28 Sep 2026 23:30:11 +0300 Subject: [PATCH] per-session models: every session owns its model + thinking level from birth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of any pick, while composer and settings UIs agreed with each other and lied about the run. Root cause: a fossil default-settings record (settings.json chat section, frozen pre-slots-wave) was stamped verbatim into every new chat at birth by the new-session flow, and resolveConversationSpec prefers the per-chat record over the strong slot forever. Owner redesign — models are per session, always; the strong slot is the new-session default: - POST /api/chats stamps the newborn verbatim from the strong slot (model + thinking level); no client settings PUT on create anymore - the composer control is dual-bound: in-session it reads/writes the session's own record (GET /api/chats/:id, PUT .../settings); on the draft surfaces it binds the strong slot (picking there = changing the default) - thinking levels follow the identical mechanics (confirmed by owner) - the zombie default-settings system dies whole: useDefaultSettings, /api/settings/defaults, src/store/default-settings.ts, the chat section of UserSettings (sanitizer strips fossil copies), the legacy default-settings.json fold, and DEFAULT_SETTINGS_FILE - catalogDefaultsFor collapses to the strong slot alone - thinking display for a legacy unstamped session resolves exactly like the engine (session ?? strong ?? model ladder) Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot, the unset level, and the copy-not-live-view regression; defaults pins rewritten strong-only; legacy-migration suite deleted. --- app/api/chats/[id]/settings/route.ts | 4 + app/api/chats/route.ts | 16 +++- app/api/settings/defaults/route.ts | 53 ------------ app/chat/new/page.tsx | 14 ++-- app/page.tsx | 11 ++- components/chat.tsx | 1 + components/default-settings.ts | 44 ---------- components/home.tsx | 23 ++---- components/model-picker.tsx | 115 +++++++++++++++++++++------ components/new-session.tsx | 42 ++-------- components/stats-bar.tsx | 8 +- config.ts | 18 +---- docs/architecture.md | 6 +- src/core/ports.ts | 5 +- src/core/settings-resolution.ts | 4 - src/core/settings.ts | 17 ++-- src/server/index.ts | 1 - src/server/model-catalog.ts | 30 +++---- src/store/default-settings.ts | 36 --------- src/store/settings.ts | 38 ++------- tests/chat-birth-stamp.test.ts | 108 +++++++++++++++++++++++++ tests/default-settings.test.ts | 54 ------------- tests/model-catalog.test.ts | 35 +++----- tests/render-new-session.test.tsx | 33 ++------ tests/settings-resolution.test.ts | 3 - tests/settings-store.test.ts | 107 ++++++++----------------- tests/settings.test.ts | 1 - 27 files changed, 336 insertions(+), 491 deletions(-) delete mode 100644 app/api/settings/defaults/route.ts delete mode 100644 components/default-settings.ts delete mode 100644 src/store/default-settings.ts create mode 100644 tests/chat-birth-stamp.test.ts delete mode 100644 tests/default-settings.test.ts diff --git a/app/api/chats/[id]/settings/route.ts b/app/api/chats/[id]/settings/route.ts index ef8e38f..2436ea8 100644 --- a/app/api/chats/[id]/settings/route.ts +++ b/app/api/chats/[id]/settings/route.ts @@ -11,6 +11,10 @@ import type { ChatSettings } from "@/src/core"; /** * The per-chat model control (model-control wave): replace-whole settings — * the picker sends the complete desired object ({} clears all overrides). + * PER-SESSION MODELS (owner, 2026-09-28): this route is THE in-session + * writer — the composer's control binds the session's own record; the + * strong slot is only the birth default and never reaches an existing + * session again. * Validation is the catalog gate (src/server/model-catalog.ts): unknown * provider/model is a 400. Thinking levels never reject (owner, * interlude 2026-09-22): a pinned level clamps to the named model's diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 0f01528..08369e4 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,4 +1,4 @@ -import { createChat, isTurnRunning, listChats, titleFromPrompt } from "@/src/server"; +import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server"; import type { ChatListItem } from "@/src/core"; /** The session picker's list: record-derived summaries (phase, pending @@ -20,5 +20,19 @@ export async function POST(req: Request) { const body = (await req.json().catch(() => ({}))) as { title?: unknown }; const title = typeof body.title === "string" ? body.title : ""; const chat = createChat(titleFromPrompt(title || "Untitled session")); + // PER-SESSION MODELS (owner, 2026-09-28): a session owns its model + + // thinking level from birth — stamped verbatim from the STRONG slot, + // the new-session default. From here the strong slot never reaches + // this session again: in-session picks write the session's own record + // (PUT /api/chats/:id/settings). No strong slot configured (fresh BYOK + // boot) → no stamp; the turn gate's 503 models_not_configured envelope + // refuses honestly until the setup flow fills the slot. + const strong = readSettingsRecord()?.models?.strong; + if (strong?.model) { + stores.chatStore.saveSettings(chat.id, { + model: { provider: strong.model.provider, id: strong.model.id }, + thinkingLevel: strong.thinkingLevel, + }); + } return Response.json({ id: chat.id, title: chat.title }); } diff --git a/app/api/settings/defaults/route.ts b/app/api/settings/defaults/route.ts deleted file mode 100644 index 931a894..0000000 --- a/app/api/settings/defaults/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * GET/PUT /api/settings/defaults — server persistence for the DEFAULT - * settings: THE default model + thinking level + knowledge-context - * budget. The new-session surfaces' controls (the home graph view's - * topic dock and /chat/new) ARE the settings page for the default model - * (owner, 2026-09-18): there is no baked-in default — BYOK first boot - * renders "no model" until the user picks one, and the last choice IS - * the default from then on. Both surfaces share ONE record; the file - * lives at data/default-settings.json (config.DEFAULT_SETTINGS_FILE). - * - * GET → 200 {settings} once saved, 200 null on first boot ("no model"). - * PUT → 200 {settings} — the VALIDATED copy, level-clamped, persisted. - * The exact per-chat contract (parseSettingsBody + the catalog - * gate): an unknown provider/model is a 400. Thinking levels - * never reject (owner, interlude 2026-09-22): a pinned level - * clamps to the named model's nearest supported and the clamped - * value is stored — model swaps just work. - * - * Per-session settings (PUT /api/chats/:id/settings) are a separate record - * untouched by this — the default still rides along on session creation - * exactly as before. - */ -import { - clampChatLevel, - effectiveSettings, - parseSettingsBody, - readDefaultSettings, - validateChatSettings, - writeDefaultSettings, -} from "@/src/server"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - return Response.json(readDefaultSettings()); -} - -export async function PUT(request: Request) { - const body = await request.json().catch(() => null); - const parsed = parseSettingsBody(body); - if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 }); - const verdict = validateChatSettings(parsed.settings); - if (!verdict.ok) { - return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); - } - // Clamp-on-write (owner, interlude 2026-09-22): a pinned thinking - // level never rejects the write — it clamps to the named model's - // nearest supported level (body model, else the record's strong - // slot) and the clamped value is stored. - const settings = clampChatLevel(parsed.settings, effectiveSettings().models.strong); - writeDefaultSettings(settings); - return Response.json({ settings }); -} diff --git a/app/chat/new/page.tsx b/app/chat/new/page.tsx index 423ea76..6b06c56 100644 --- a/app/chat/new/page.tsx +++ b/app/chat/new/page.tsx @@ -1,5 +1,5 @@ import { NewSession } from "@/components/new-session"; -import { readDefaultSettings, readSettingsRecord } from "@/src/server"; +import { readSettingsRecord } from "@/src/server"; import { slotsConfigured } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -11,16 +11,12 @@ export const dynamic = "force-dynamic"; * the real page, whose initialPrompt path delivers the message as turn 1. * Static segment beats the dynamic [id] route — "new" is never a chat id. * - * The default settings record is server-rendered in (readDefaultSettings → - * initialSettings): the controls show truth on the first frame, no - * default-model flash (the default IS the user's last choice — "no model" - * only before the first-ever pick). + * The draft's model control binds the STRONG SLOT (the new-session + * default); the session the first send creates is stamped from it + * server-side (per-session models, owner 2026-09-28). */ export default function NewSessionPage() { return ( - + ); } diff --git a/app/page.tsx b/app/page.tsx index f88010d..4e89922 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,10 @@ import { Home } from "@/components/home"; -import { readDefaultSettings, readSettingsRecord } from "@/src/server"; +import { readSettingsRecord } from "@/src/server"; import { slotsConfigured } from "@/src/core"; -// Dynamic: the default settings record is server-rendered into the page — -// the model + thinking + context controls show truth on the FIRST frame -// (no default-model flash), and every navigation re-reads the record -// (dynamic routes aren't cached client-side). +// Dynamic: the configured state is server-rendered into the page (the +// setup CTA's truth on the first frame), and every navigation re-reads +// the record (dynamic routes aren't cached client-side). export const dynamic = "force-dynamic"; export default function HomePage() { @@ -14,6 +13,6 @@ export default function HomePage() { // record's models section lacks any slot pin. const record = readSettingsRecord(); return ( - + ); } diff --git a/components/chat.tsx b/components/chat.tsx index e0aec22..bb12bb3 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1672,6 +1672,7 @@ function DockOverlay({ {/* The scroll-down disc (moved here from the Conversation, owner refinement round 2026-09-24): anchored to the BLOCK's diff --git a/components/default-settings.ts b/components/default-settings.ts deleted file mode 100644 index 5ae592a..0000000 --- a/components/default-settings.ts +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -/** - * THE default settings (owner, 2026-09-18): the new-session surfaces' - * model + thinking + knowledge-context controls are the settings page for - * the default model. There is NO baked-in default — BYOK first boot shows - * "no model" until the user picks one, and their last choice IS the - * default from then on. Both surfaces (the home graph view's topic dock - * and /chat/new) share ONE server-side record (GET/PUT - * /api/settings/defaults → data/default-settings.json). - * - * No loading state, no flash: the PAGES server-render the record - * (readDefaultSettings → initialSettings), so the controls show truth on - * the first frame — dynamic routes aren't cached client-side (staleTimes - * .dynamic = 0), so every navigation gets fresh server truth. This hook - * only holds the state between saves: initialSettings seeds it, and every - * change applies locally (optimistic — the controls only send valid - * values: the picker offers catalog models, the slider sends its stops) - * and persists fire-and-forget; a failed write logs, and the next - * navigation re-reads the persisted truth. - */ - -import { useCallback, useState } from "react"; -import type { ChatSettings } from "@/src/core"; - -export function useDefaultSettings(initialSettings: ChatSettings | null): { - settings: ChatSettings | undefined; - save: (settings: ChatSettings) => void; -} { - const [settings, setSettings] = useState(initialSettings ?? undefined); - - const save = useCallback((next: ChatSettings) => { - setSettings(next); - void fetch("/api/settings/defaults", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(next), - }).catch((err: unknown) => { - console.error("default settings write failed:", err); - }); - }, []); - - return { settings, save }; -} diff --git a/components/home.tsx b/components/home.tsx index 2161978..507cfdb 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -6,11 +6,10 @@ import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; -import { useDefaultSettings } from "@/components/default-settings"; import { DictationBar, useDictation } from "@/components/dictation"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; -import type { ChatSettings, TurnUsage } from "@/src/core"; +import type { TurnUsage } from "@/src/core"; /** * Home — the knowledge graph is the main view, full-bleed edge to edge @@ -18,7 +17,7 @@ import type { ChatSettings, TurnUsage } from "@/src/core"; * under the header and the new-session dock, both of which float over it * with no gradient scrims. Sessions live in the toggleable sidebar. */ -export function Home({ initialSettings, initialConfigured }: { initialSettings: ChatSettings | null; initialConfigured: boolean }) { +export function Home({ initialConfigured }: { initialConfigured: boolean }) { const router = useRouter(); const [topic, setTopic] = useState(""); // First-run gate (model-slots wave, owner 2026-09-20): chats cannot @@ -41,13 +40,6 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: append: (t) => setTopic((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)), }); const [busy, setBusy] = useState(false); - // THE default settings (owner, 2026-09-18): this topic dock IS the - // settings page for the default model — no baked-in default exists; the - // user's last choice (server-rendered in via initialSettings, shared - // with /chat/new through data/default-settings.json) IS the default. - // Still carried to the chat POST then PUT-on-create (the model picker - // defers its save when chatId is empty; see components/model-picker). - const { settings, save: saveDraft } = useDefaultSettings(initialSettings); const start = async (force = false) => { const t = topic.trim(); @@ -79,14 +71,9 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: body: JSON.stringify({ title: t }), }); const { id } = (await res.json()) as { id: string }; - if (settings) { - // Best-effort persist — the selection was made pre-chat. - await fetch(`/api/chats/${id}/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settings), - }).catch(() => {}); - } + // No settings PUT on create (per-session models, owner + // 2026-09-28): the SERVER stamps the newborn from the strong + // slot — the same default this surface's model control edits. router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); } catch { setBusy(false); diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 59536d1..a09d522 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core"; +import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { prettyModel } from "@/components/usage-format"; import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; @@ -77,23 +77,25 @@ export function catalogLimits(catalog: Pick): Ma } /** - * THE STRONG-SLOT CONTROL (fix wave, owner ruling: "one and the same"). - * The composer's model + thinking pills ARE the strong slot — the tier - * that runs prober + teacher. Reads: the raw models section (GET - * /api/settings) for the user's pick, plus the federated catalog for the - * menus. Writes: PUT /api/settings/models via composeSlotModel — the same - * seam the settings Agent section and the SetupDialog write, so there is - * exactly one strong slot in the world and every surface edits it. + * THE CONVERSATION MODEL CONTROL — dual-bound (owner, per-session models, + * 2026-09-28). WITH a chatId (the in-session stats bar): the pills bind + * THE SESSION'S OWN record — reads GET /api/chats/:id (its settings field; + * a legacy unstamped record falls back to the strong slot's display, + * exactly what the engine resolves), writes PUT /api/chats/:id/settings + * (validated + level-clamped server-side). The session owns its model + + * thinking level from birth; the strong slot never reaches it again. * - * Unpicked reads "—" (the no-factory-fallback language: the UI never - * presents a default model; the engine-side pin still resolves, the UI - * never claims it). Legacy per-chat model overrides on old records keep - * resolving server-side — the UI simply no longer writes that field - * anywhere. + * WITHOUT a chatId (the draft surfaces — home + /chat/new): the pills + * bind the STRONG SLOT (reads the raw models section, GET /api/settings; + * writes PUT /api/settings/models via composeSlotModel — the same seam + * the settings Agent section and the SetupDialog write): picking there IS + * changing the new-session default. Unpicked reads "—" (the + * no-factory-fallback language: the UI never presents a default model). */ -export function ModelControl() { +export function ModelControl({ chatId }: { chatId?: string }) { const { catalog, refresh } = useModelCatalog(); const [models, setModels] = useState(); + const [session, setSession] = useState(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -101,7 +103,19 @@ export function ModelControl() { // The record read — BOTH mount and the cross-surface signal land // here (fix wave: another surface's models write re-reads into the // pill live; own writes also notify, and the refetch is idempotent). + // Dual-bound: chat mode reads BOTH the session record (the source of + // truth) and the models section (the display fallback for a legacy + // unstamped session — exactly what the engine resolves); draft mode + // reads the models section alone. const readRecord = useCallback(() => { + if (chatId) { + fetch(`/api/chats/${chatId}`) + .then((r) => r.json() as Promise<{ settings?: ChatSettings }>) + .then((body) => setSession(body.settings)) + .catch(() => { + /* same read-only posture as a models-section failure */ + }); + } fetch("/api/settings") .then((r) => r.json() as Promise<{ settings: { models?: ModelsSettings } | null }>) .then((body) => setModels(body.settings?.models ?? undefined)) @@ -110,7 +124,7 @@ export function ModelControl() { ("—" triggers, closed menus) — same posture as a catalog failure */ }); - }, []); + }, [chatId]); useEffect(() => { readRecord(); @@ -120,7 +134,17 @@ export function ModelControl() { if (section === "models") readRecord(); }); - const override = models?.strong; + // 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. + const strong = models?.strong; + const override: ChatSettings | undefined = chatId + ? { + model: session?.model ?? strong?.model, + thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel, + } + : strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; const storedLevel = override?.thinkingLevel; @@ -200,6 +224,51 @@ export function ModelControl() { [], ); + // The session write seam (chat mode): PUT the per-chat settings route + // — validated + level-clamped server-side; the response is the stored + // truth (a pick riding an unsupported level lands CLAMPED — the pill + // shows the stored value). No notifySettingsSaved: the write touches + // one session's record, not the slots — no other surface reads it. + const saveChat = useCallback( + async (next: ChatSettings) => { + setError(null); + try { + const res = await fetch(`/api/chats/${chatId}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(next), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? `settings rejected (${res.status})`); + return; + } + const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null; + setSession(stored?.settings ?? next); + setOpen(null); + } catch { + setError("Could not reach the server — try again."); + } + }, + [chatId], + ); + + // One apply seam over both write paths: a pick names provider + id + + // level; chat mode writes the session's own record (any pick pins it), + // draft mode composes the strong slot (siblings preserved, + // replace-whole-section discipline). + const apply = useCallback( + (provider: string, id: string, level: ThinkingLevel | undefined) => { + if (chatId) { + void saveChat({ model: { provider, id }, thinkingLevel: level }); + } else { + // composeSlotModel's empty-string convention: no level = unset. + void save(composeSlotModel(models, "strong", provider, id, level ?? "")); + } + }, + [chatId, models, save, saveChat], + ); + // Shortcut seams (keyboard wave A): ⌘K opens the model menu (the // universal command convention); Shift+Tab cycles the thinking level // up the ladder (wrapping) — the owner's replacement for browser @@ -220,11 +289,11 @@ export function ModelControl() { const idx = effectiveLevel === undefined ? -1 : levels.indexOf(effectiveLevel); const next = levels[(idx + 1) % levels.length]; if (next === undefined) return; - void save(composeSlotModel(models, "strong", effectiveProvider ?? "", effectiveId ?? "", next)); + apply(effectiveProvider ?? "", effectiveId ?? "", next); }; window.addEventListener("lh:kbd-cycle-thinking", onCycleThinking); return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking); - }, [levels, effectiveLevel, effectiveProvider, effectiveId, models, save]); + }, [levels, effectiveLevel, effectiveProvider, effectiveId, apply]); // Selecting a model (click and Enter share one path): keep the // thinking level only when the new model supports it — otherwise @@ -239,7 +308,7 @@ export function ModelControl() { // rides — it belongs to the OLD model; the fresh pick's level comes // from the fresh model's own ladder.) const nextLevel = populateLevel(storedLevel, m.supportedLevels); - void save(composeSlotModel(models, "strong", m.provider, m.id, nextLevel)); + apply(m.provider, m.id, nextLevel); }; // Stale-while-revalidate on manual open too (⌘K covers the keyboard @@ -328,7 +397,9 @@ export function ModelControl() { footer={ catalog && catalog.models.length === 0 ? "No models — add a provider key in Keys" - : "Strong tier — runs prober + teacher, app-wide" + : chatId + ? "This session's model" + : "Strong tier — the default for new sessions" } /> @@ -344,9 +415,7 @@ export function ModelControl() { - void save(composeSlotModel(models, "strong", effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)) - } + onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)} empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"} footer="Levels this model actually supports" /> diff --git a/components/new-session.tsx b/components/new-session.tsx index 74e99db..2a0cef2 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; -import { useDefaultSettings } from "@/components/default-settings"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; @@ -19,9 +18,9 @@ import type { ChatSettings } from "@/src/core"; * the session's title (the exact create-then-redirect contract the home * composer established). Visually identical to a fresh chat: same header * (title + probe chip), the same empty state, the same composer blob + - * stats dock. The model picker's empty-chatId branch defers its save and - * lifts the selection here, so the pre-chat model/thinking choice rides - * the creation. + * stats dock. The draft's model control binds the strong slot (the + * new-session default); the created session is stamped from it + * server-side (per-session models, owner 2026-09-28). */ const DOCK = "mx-auto w-full max-w-[46rem] px-4"; @@ -36,7 +35,6 @@ const DOCK = "mx-auto w-full max-w-[46rem] px-4"; */ export async function startDraftSession( text: string, - settings: ChatSettings | undefined, fetchLike: typeof fetch, ): Promise { const t = text.trim(); @@ -47,14 +45,8 @@ export async function startDraftSession( body: JSON.stringify({ title: t }), }); const { id } = (await res.json()) as { id: string }; - if (settings) { - // Best-effort persist — the selection was made pre-chat. - await fetchLike(`/api/chats/${id}/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settings), - }).catch(() => {}); - } + // No settings PUT on create (per-session models, owner 2026-09-28): + // the SERVER stamps the newborn from the strong slot. return `/chat/${id}?prompt=${encodeURIComponent(t)}`; } @@ -65,16 +57,12 @@ export function DraftShell({ text, onChange, onSend, - settings, - onSettingsSaved, setupCta, }: { busy: boolean; text: string; onChange: (v: string) => void; onSend: () => void; - settings: ChatSettings | undefined; - onSettingsSaved: (s: ChatSettings) => void; /** The first-run CTA (fix wave chunk B): the same "Set up your models" * affordance home carries, rendered above the draft shell — passed by * NewSession, which owns the configured state and the SetupDialog. */ @@ -143,22 +131,10 @@ export function DraftShell({ ); } -export function NewSession({ - initialSettings, - initialConfigured, -}: { - initialSettings: ChatSettings | null; - initialConfigured: boolean; -}) { +export function NewSession({ initialConfigured }: { initialConfigured: boolean }) { const router = useRouter(); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); - // THE default settings (owner, 2026-09-18): this surface and the home - // topic dock carries only the knowledge-context budget now (fix - // wave): the model pills ARE the strong slot (PUT /api/settings/models, - // app-wide) — the per-chat model pick is retired. saveDraft still - // lifts the context budget so it rides the session POST. - const { settings, save: saveDraft } = useDefaultSettings(initialSettings); // First-run gate (model-slots wave, owner 2026-09-20): the send // INTERCEPTS into the setup flow while unconfigured — no defaults for @@ -193,13 +169,13 @@ export function NewSession({ return; } setBusy(true); - void startDraftSession(text, settings, fetch) + void startDraftSession(text, fetch) .then((target) => { if (target) router.push(target); else setBusy(false); }) .catch(() => setBusy(false)); - }, [text, busy, settings, router, configured, zeroKeys, strongKeyless]); + }, [text, busy, router, configured, zeroKeys, strongKeyless]); return ( <> @@ -208,8 +184,6 @@ export function NewSession({ text={text} onChange={setText} onSend={send} - settings={settings} - onSettingsSaved={saveDraft} setupCta={ zeroKeys ? ( setSetupOpen(true)} /> diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 718863a..d5be512 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -29,8 +29,14 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model export function StatsBar({ turns, dictationSlot, + chatId, }: { turns: TurnUsage[]; + /** The bound session (per-session models, owner 2026-09-28): present + * on the in-session bar — the model control binds THE SESSION'S own + * record; absent on the draft surfaces, where it binds the strong + * slot (the new-session default). */ + chatId?: string; /** 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 @@ -169,7 +175,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/config.ts b/config.ts index 3cf9c5b..f9efe5b 100644 --- a/config.ts +++ b/config.ts @@ -83,20 +83,10 @@ export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json"); */ export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json"); -/** - * DEFAULT settings — THE default model + thinking level + knowledge-context - * budget (owner, 2026-09-18): the new-session surfaces' controls are the - * settings page for the default model. There is NO baked-in default — - * BYOK first boot shows "no model" until the user picks one, and their - * last choice IS the default from then on. One small JSON blob, - * read-modify-write, written only through app/api/settings/defaults. - */ -export const DEFAULT_SETTINGS_FILE = path.join(DATA_ROOT, "default-settings.json"); - /** * THE USER SETTINGS RECORD (settings-storage wave, 2026-09-19): one * versioned, DELTA-shaped JSON blob at the data-dir seam holding every - * non-secret product preference — general, chat defaults, agent (background + * non-secret product preference — general, agent (background * model/thinking overrides + tool toggles + prompt-override switches), * appearance, graph. Explicit user choices only: ABSENT keys resolve to the * factory defaults compiled into src/core/settings.ts (FACTORY_SETTINGS), so @@ -105,9 +95,9 @@ export const DEFAULT_SETTINGS_FILE = path.join(DATA_ROOT, "default-settings.json * version field is a PRIVATE schema counter for breaking shape changes * only (field renamed/meaning changed), unrelated to the app release * version. Secrets (API keys, login tokens) NEVER live here — auth.json / - * .env.local keep that role. Migrated FROM: default-settings.json folds - * into the record's `chat` section on first read (the old file is left in - * place, inert). + * .env.local keep that role. (The `chat` default-settings section died + * with per-session models, owner 2026-09-28 — sessions own their model + * from birth, stamped from the strong slot.) */ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); diff --git a/docs/architecture.md b/docs/architecture.md index 93b1047..da3ed7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,7 @@ flowchart TB subgraph STORE["STORE — src/store — fs adapters, only data/ writers"] ADP["chats.ts · graph.ts · jobs.ts (implement core ports)"] - PORTLESS["render-settings.ts · default-settings.ts (portless)"] + PORTLESS["render-settings.ts (portless)"] end CFG["config.ts — leaf facts: PATHS · LEDGER_FILES · engine-construction facts (no models — BYOK slots own those)"] @@ -156,7 +156,7 @@ flowchart TB ## 3. Ports, adapters, and the composition roots -Storage is **per-entity semantic ports**, never one generic interface: `ChatStore`, `GraphStore`, `JobStore` (`src/core/ports.ts` — types only). The fs adapters (`src/store/{chats,graph,jobs}.ts`) implement them and contain persistence and nothing else. Two more adapters (`src/store/{render-settings,default-settings}.ts`) are portless free functions re-exported through the server barrel — a deliberate pragmatic bypass, documented here so nobody mistakes it for the pattern. +Storage is **per-entity semantic ports**, never one generic interface: `ChatStore`, `GraphStore`, `JobStore` (`src/core/ports.ts` — types only). The fs adapters (`src/store/{chats,graph,jobs}.ts`) implement them and contain persistence and nothing else. One more adapter (`src/store/render-settings.ts`) is a portless free function re-exported through the server barrel — a deliberate pragmatic bypass, documented here so nobody mistakes it for the pattern. **Idioms.** Graph evidence lives in **append-only JSONL ledgers** (corrupt-line-tolerant reads; strict shape guards drop invalid lines, never guess). Chats, jobs, positions, sources, and the settings blobs are whole-file read-modify-write JSON. All writes are synchronous, single-process (atomicity rides the Node event loop). Merge/dedup/derivation semantics are **reader-side, in core** — the store never decides. @@ -164,7 +164,7 @@ Storage is **per-entity semantic ports**, never one generic interface: `ChatStor - **`src/server/chat-handler.ts` is THE composition root for a turn**: it wires the stores, the job runner, the extraction pipeline, and the pi engine into the stream response (`setTurnRunner`/`setJobRespawn` register its seams for the wake pipeline). All logic lives behind the seams it calls. - The agent layer is dependency-inverted throughout: `createJobRunner({ chatStore, jobStore, deliver })`, `createExtractionPipeline({ chatStore, graphStore, extractor?, auditor?, embedder })`, `createTeacherTools({ chatId, chatStore, jobStore, spawn })`, `createProberAgent`/`createTeacherAgent(engine?, tools?, …)`. Tests inject the same ports production wires — or swap an in-memory port. -The engine side is adapted too: `src/agent/models.ts` (`piEngineFor`) resolves each role's engine from the user's **model slots** (BYOK: `settings.json` `models` section — strong/cheap/vision — plus per-role/chat overrides; a chain that ends empty refuses honestly with 503 `models_not_configured`), and `src/server/pi/` bridges the two SDK worlds: `record-adapter.ts` (persisted UIMessage record → stateless pi `Message[]`, dangling tool calls get synthesized INTERRUPTED results) and `ui-stream.ts` (pi agent events → AI-SDK UIMessage SSE). +The engine side is adapted too: `src/agent/models.ts` (`piEngineFor`) resolves each role's engine from the user's **model slots** (BYOK: `settings.json` `models` section — strong/cheap/vision; a chain that ends empty refuses honestly with 503 `models_not_configured`) — and the teacher/prober conversation runs the **SESSION'S OWN record**: `POST /api/chats` stamps every newborn verbatim from the strong slot (the new-session default, chosen in setup), in-session picks write the session's record (`PUT /api/chats/:id/settings`), and the strong slot never reaches an existing session again (legacy unstamped records fall back to it at resolve time) — per-session models, owner 2026-09-28. Beyond the conversation, `src/server/pi/` bridges the two SDK worlds: `record-adapter.ts` (persisted UIMessage record → stateless pi `Message[]`, dangling tool calls get synthesized INTERRUPTED results) and `ui-stream.ts` (pi agent events → AI-SDK UIMessage SSE). ```mermaid flowchart LR diff --git a/src/core/ports.ts b/src/core/ports.ts index 94436d4..bcb9419 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -170,8 +170,9 @@ export interface JobStore { * UserSettings — delta semantics, see that module's doc). The store is * replace-whole on the RECORD; per-section scoping is a server-band concern * (single process, no stale-client hazard to defend against at this seam). - * Lazy migration (default-settings.json → the record's chat section, schema - * version bumps) happens inside readSettings, never in the callers. + * Schema-version lazy migration happens inside readSettings, never in + * the callers. (The legacy default-settings.json fold died with the chat + * section — per-session models, owner 2026-09-28.) */ export interface SettingsStore { /** The sanitized record, or null when none exists yet (first boot). */ diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index aa70015..c34b20d 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -68,9 +68,6 @@ export type EffectiveAppearance = FactorySettings["appearance"]; /** The concrete view: every knob resolved, no absent fields. */ export interface EffectiveSettings { general: Required; - /** The default ChatSettings for new sessions — passthrough delta - * ({} = no default = BYOK "no model" first boot). */ - chat: ChatSettings; agent: { models: Record; /** Delta map as stored — consumers resolve absent key → enabled. */ @@ -175,7 +172,6 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings general: { autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, }, - chat: record?.chat ?? {}, agent: { models, tools: agent.tools ?? {}, diff --git a/src/core/settings.ts b/src/core/settings.ts index 7b13e8c..fbfbe28 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -333,9 +333,6 @@ export interface GraphSettings { export interface UserSettings { version: number; general?: GeneralSettings; - /** Today's default-settings.json content — the new-session surfaces' - * controls. NOT shown in the settings panel (owner decision). */ - chat?: ChatSettings; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -570,13 +567,6 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) { out.general = sanitizeGeneral(r.general as Record); } - if (r.chat !== null && typeof r.chat === "object" && !Array.isArray(r.chat)) { - // The chat section is ChatSettings — its fields are plain and the - // section is written exclusively through the validated route - // (parseSettingsBody + validateChatSettings), so pass through as-is; - // a hand-corrupted value fails loudly downstream, same as today. - out.chat = r.chat as ChatSettings; - } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } @@ -590,8 +580,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) { out.graph = sanitizeGraph(r.graph as Record); } - // Unknown top-level keys ride along untouched (forward-compat). + // Unknown top-level keys ride along untouched (forward-compat). One + // known-DEAD key is stripped instead of riding: `chat` (the old + // default-settings section) died with per-session models (owner, + // 2026-09-28) — a fossil copy on disk must never resurrect through + // reads (and any later write flushes it from the file for good). for (const [key, value] of Object.entries(r)) { + if (key === "chat") continue; if (!(key in out)) out[key] = value; } return out; diff --git a/src/server/index.ts b/src/server/index.ts index 3a2088e..c1ead82 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,7 +11,6 @@ export * from "./commit-positions.ts"; export * from "./jobs.ts"; export * from "./stores.ts"; export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts"; -export { readDefaultSettings, writeDefaultSettings } from "../store/default-settings.ts"; // The settings record + prompt overrides (settings-storage wave, 2026-09-19). // The *Record aliases keep the app/api/settings routes explicit about which // seam they touch (the raw record, not the chat-section convenience view). diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 80263ff..58b5cfc 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -39,20 +39,24 @@ import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type Mod /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ export type ModelDto = ModelCatalogEntry; -/** The conversation agents' resolved default (pure form — the record's chat - * section + the strong slot in, CatalogDefaults out; `catalogDefaults()` - * below is the fs wrapper). BYOK (owner, 2026-09-22): there is no factory - * floor — when neither the chat section nor the strong slot names a - * model, the default is NULL and the turn gate refuses with the 503 - * models_not_configured envelope. An unset thinkingLevel resolves from - * DEFAULT_THINKING_ORDER at engine build (reported as absent here). */ +/** + * The conversation agents' resolved default (pure form — the strong slot + * in, CatalogDefaults out; `catalogDefaults()` below is the fs wrapper). + * PER-SESSION MODELS (owner, 2026-09-28): every session owns its model + + * thinking level from birth, stamped verbatim from the strong slot — the + * STRONG SLOT IS THE NEW-SESSION DEFAULT, and the resolve-time fallback + * for legacy settings-less records. BYOK (owner, 2026-09-22): there is no + * factory floor — when the strong slot names no model, the default is + * NULL and the turn gate refuses with the 503 models_not_configured + * envelope. An unset thinkingLevel resolves from DEFAULT_THINKING_ORDER at + * engine build (reported as absent here). + */ export function catalogDefaultsFor( - chat: ChatSettings | undefined, strong: EffectiveSlot | null, ): CatalogDefaults | null { - const model = chat?.model ?? (strong ? { provider: strong.provider, id: strong.id } : undefined); + const model = strong ? { provider: strong.provider, id: strong.id } : undefined; if (!model) return null; - const level = chat?.thinkingLevel ?? strong?.thinkingLevel; + const level = strong?.thinkingLevel; return { model, ...(level !== undefined ? { thinkingLevel: level } : {}), @@ -60,11 +64,9 @@ export function catalogDefaultsFor( } /** The conversation agents' resolved default — what a settings-less chat - * runs. (settings-storage wave, 2026-09-19): the user's picked default - * rides in the settings record's `chat` section; the picker's defaults - * row shows it once chosen. */ + * runs: the strong slot, i.e. the birth-default for new sessions. */ export function catalogDefaults(): CatalogDefaults | null { - return catalogDefaultsFor(settingsStore.readSettings()?.chat, effectiveSettings().models.strong); + return catalogDefaultsFor(effectiveSettings().models.strong); } /** Project the resolved per-role specs to the client DTO (pure — the diff --git a/src/store/default-settings.ts b/src/store/default-settings.ts deleted file mode 100644 index 0808016..0000000 --- a/src/store/default-settings.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Server-side persistence for the DEFAULT CHAT settings — THE default - * model + thinking level + knowledge-context budget (owner, 2026-09-18): - * the new-session surfaces' controls (the home graph view's topic dock and - * /chat/new) ARE the settings page for the default model. There is NO - * baked-in default — BYOK first boot renders "no model" until the user - * picks one, and their last choice IS the default from then on. The pages - * server-render from this record (initialSettings), so the controls show - * truth on the first frame — no default-model flash ever. Per-session - * settings live in the chat record (PUT /api/chats/:id/settings) and are - * untouched by this; new sessions inherit the default on creation. - * - * BACKED BY THE SETTINGS RECORD (settings-storage wave, 2026-09-19): the - * default chat settings are the `chat` section of data/settings.json - * (src/store/settings.ts — same delta semantics, same file, one record). - * The legacy data/default-settings.json folds into that section on first - * read. These signatures are unchanged so the pages and the legacy PUT - * route (app/api/settings/defaults) keep working untouched; writes are - * still validated by the route before they get here (parseSettingsBody + - * validateChatSettings). - */ - -import type { ChatSettings } from "../core"; -import { readSettings, writeSettings } from "./settings.ts"; - -/** The persisted default chat settings, or null when never saved / unreadable. */ -export function readDefaultSettings(): ChatSettings | null { - return readSettings()?.chat ?? null; -} - -/** Persist the validated settings into the record's chat section — other - * sections ride along untouched (read-modify-write on the ONE record). */ -export function writeDefaultSettings(settings: ChatSettings): void { - const record = readSettings(); - writeSettings({ ...(record ?? { version: 1 }), chat: settings }); -} diff --git a/src/store/settings.ts b/src/store/settings.ts index 745f02b..1a09d22 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -5,13 +5,11 @@ * preferences only; secrets stay in auth.json / .env.local. * * This adapter OWNS the lazy migrations: - * 1. first boot with a legacy default-settings.json (the 2026-09-18 wave's - * file) but no settings.json → fold it into the record's `chat` section, - * persist the new record, LEAVE the old file in place (inert, zero-risk - * rollback). Subsequent reads go straight to settings.json. - * 2. schema-version bumps (CURRENT_SETTINGS_VERSION) slot in here — the + * 1. schema-version bumps (CURRENT_SETTINGS_VERSION) slot in here — the * chain is empty today (v1 is the first shape); pure additions never * bump, so an up-to-date file is always a no-op pass-through. + * (The legacy default-settings.json fold died with the chat section — + * per-session models, owner 2026-09-28.) * * Everything off disk is sanitized (core/settings.ts) before it reaches a * caller; everything written was already validated by the routes @@ -21,14 +19,12 @@ import fs from "node:fs"; import path from "node:path"; -import { DEFAULT_SETTINGS_FILE, SETTINGS_FILE } from "../../config.ts"; +import { SETTINGS_FILE } from "../../config.ts"; import { - CURRENT_SETTINGS_VERSION, sanitizeUserSettings, type UserSettings, } from "../core/settings.ts"; import type { SettingsStore } from "../core/ports.ts"; -import type { ChatSettings } from "../core"; /** The persisted record, or null when none exists (first boot). Runs the * migration chain on every read — cheap: a no-op file check when current. */ @@ -37,13 +33,7 @@ export function readSettings(): UserSettings | null { try { raw = fs.readFileSync(SETTINGS_FILE, "utf-8"); } catch { - // No settings.json yet — one-time legacy fold: the default-settings - // wave's record becomes our record's `chat` section verbatim. - const legacy = readLegacyDefaultSettings(); - if (!legacy) return null; - const migrated: UserSettings = { version: CURRENT_SETTINGS_VERSION, chat: legacy }; - writeSettings(migrated); - return migrated; + return null; // no settings.json yet — first boot, all factory defaults } let parsed: unknown; try { @@ -68,24 +58,6 @@ export function writeSettings(raw: unknown): UserSettings { return settings; } -/** The legacy default-settings.json contents, or null when absent/corrupt — - * same tolerance as the old store: unreadable = never saved, never crash. */ -function readLegacyDefaultSettings(): ChatSettings | null { - let raw: string; - try { - raw = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8"); - } catch { - return null; - } - try { - const parsed: unknown = JSON.parse(raw); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; - return parsed as ChatSettings; - } catch { - return null; - } -} - /** The typed adapter (core/ports.ts SettingsStore), for the composition root. */ export const settingsStore: SettingsStore = { readSettings, diff --git a/tests/chat-birth-stamp.test.ts b/tests/chat-birth-stamp.test.ts new file mode 100644 index 0000000..4c91558 --- /dev/null +++ b/tests/chat-birth-stamp.test.ts @@ -0,0 +1,108 @@ +/** + * The BIRTH STAMP (per-session models, owner ruling 2026-09-28): every + * session owns its model + thinking level from birth — POST /api/chats + * stamps the newborn chat verbatim from the settings record's STRONG + * slot, the new-session default. From then on the strong slot never + * reaches the session again (in-session picks write the session's own + * record; resolveConversationSpec prefers chat.settings.model). + * + * This pin drives the actual route handler (framework-free + * Request/Response) under an isolated LH_DATA_DIR — the seam must be set + * before the first import that transitively loads config.ts, so every + * app-module import is dynamic (the prompt-overrides discipline). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// ── the seam, BEFORE any config-loading import ────────────────────────────── +const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-birth-stamp-")); +process.env.LH_DATA_DIR = TMP_DATA; + +const { POST } = await import("../app/api/chats/route.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { chatStore } = await import("../src/store/chats.ts"); + +after(() => { + fs.rmSync(TMP_DATA, { recursive: true, force: true }); +}); + +function post(): Promise { + return POST(new Request("http://test/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "teach me the chain rule" }), + })); +} + +describe("POST /api/chats — the birth stamp", () => { + it("stamps the newborn verbatim from the strong slot (model + thinking level)", async () => { + writeSettings({ + version: 1, + models: { + strong: { + model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, + thinkingLevel: "low", + }, + }, + }); + const res = await post(); + assert.equal(res.status, 200); + const { id } = (await res.json()) as { id: string }; + const record = chatStore.getChat(id); + assert.ok(record); + assert.deepEqual(record.settings, { + model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, + thinkingLevel: "low", + }); + }); + + it("no strong slot configured (fresh BYOK boot) → NO stamp; the turn gate refuses downstream", async () => { + fs.rmSync(path.join(TMP_DATA, "settings.json"), { force: true }); + assert.equal(readSettings(), null); + const res = await post(); + assert.equal(res.status, 200); + const { id } = (await res.json()) as { id: string }; + const record = chatStore.getChat(id); + assert.ok(record); + assert.equal(record.settings, undefined, "nothing stamped — nothing to lie about"); + }); + + it("an unset thinking level rides as-is — the session's level resolves (strong's, then the model's ladder)", async () => { + writeSettings({ + version: 1, + models: { strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } } }, + }); + const res = await post(); + const { id } = (await res.json()) as { id: string }; + const record = chatStore.getChat(id); + assert.ok(record); + assert.equal(record.settings?.thinkingLevel, undefined); + assert.deepEqual(record.settings, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } }); + }); + + it("changing the strong slot afterward NEVER touches existing sessions — the stamp is a copy, not a live view", async () => { + writeSettings({ + version: 1, + models: { strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low" } }, + }); + const first = (await (await post()).json()) as { id: string }; + writeSettings({ + version: 1, + models: { strong: { model: { provider: "openai", id: "gpt-6-luna" }, thinkingLevel: "low" } }, + }); + const second = (await (await post()).json()) as { id: string }; + // The pre-change session keeps its stamp; the post-change session + // carries the new default. This is the regression pin for the + // reported bug (2026-09-28): both UIs showed the strong slot while + // every chat ran a fossil stamp. + const firstRecord = chatStore.getChat(first.id); + const secondRecord = chatStore.getChat(second.id); + assert.ok(firstRecord && secondRecord); + assert.equal(firstRecord.settings?.model?.id, "z-ai/glm-5.3-flash"); + assert.equal(secondRecord.settings?.model?.id, "gpt-6-luna"); + }); +}); diff --git a/tests/default-settings.test.ts b/tests/default-settings.test.ts deleted file mode 100644 index 264a6c4..0000000 --- a/tests/default-settings.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Tests for the shared settings-body parser, chat-section flavor - * (default-settings wave, 2026-09-18; record-backed 2026-09-19): the seam - * behind BOTH chat-settings routes — per-chat and the defaults alias. - * - * The record store, the legacy-file migration, and the record-backed - * default-settings facade are covered in tests/settings-store.test.ts - * (one fs owner per file — the test runner executes files in parallel). - */ - -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { parseSettingsBody } from "../src/server/settings-body.ts"; - -describe("parseSettingsBody (shared by both settings routes)", () => { - it("an empty object parses to {} — the clear-overrides write", () => { - const r = parseSettingsBody({}); - assert.ok(r.ok); - assert.deepEqual(r.ok ? r.settings : null, {}); - }); - - it("a full valid body parses field-for-field", () => { - const r = parseSettingsBody({ - model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, - thinkingLevel: "high", - }); - assert.ok(r.ok); - assert.deepEqual(r.settings, { - model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, - thinkingLevel: "high", - }); - }); - - it("partial bodies keep only the present overrides (replace-whole)", () => { - const r = parseSettingsBody({ thinkingLevel: "low" }); - assert.ok(r.ok); - assert.deepEqual(r.settings, { thinkingLevel: "low" }); - }); - - it("rejects non-object bodies", () => { - for (const body of ["junk", 42, [{}]]) { - const r = parseSettingsBody(body); - assert.equal(r.ok, false, JSON.stringify(body)); - if (!r.ok) assert.match(r.error, /Invalid JSON body/); - } - }); - - it("rejects malformed model and non-string thinkingLevel", () => { - assert.equal(parseSettingsBody({ model: "nope" }).ok, false); - assert.equal(parseSettingsBody({ model: { provider: 1, id: "x" } }).ok, false); - assert.equal(parseSettingsBody({ thinkingLevel: 7 }).ok, false); - }); - -}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 154b5d3..24d1366 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -86,11 +86,10 @@ describe("the model catalog projection", () => { // the record is empty (defaults/slots NULL), on the owner's machine // configured slots must surface exactly as configured. const record = readSettings(); - const chat = record?.chat; const strong = effectiveSettings().models.strong; - if (chat?.model || strong) { - assert.ok(defaults, "a configured chain yields a default"); - assert.deepEqual(defaults!.model, chat?.model ?? { provider: strong!.provider, id: strong!.id }); + if (strong) { + assert.ok(defaults, "a configured strong slot yields a default"); + assert.deepEqual(defaults!.model, { provider: strong.provider, id: strong.id }); } else { assert.equal(defaults, null); } @@ -105,29 +104,15 @@ describe("the model catalog projection", () => { } }); - it("defaults: the chat section over the strong slot; null when both empty (BYOK)", () => { - assert.equal(catalogDefaultsFor(undefined, null), null); + it("defaults: the strong slot verbatim; null when unset (BYOK, per-session models 2026-09-28)", () => { + assert.equal(catalogDefaultsFor(null), null); const strong = { provider: "openrouter", id: "z-ai/glm-5.3-flash", thinkingLevel: "low" as const }; - const d = catalogDefaultsFor(undefined, strong); + const d = catalogDefaultsFor(strong); assert.deepEqual(d, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low" }); - assert.deepEqual(catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }, strong), { - model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, - thinkingLevel: "high", - }); - // partial: only the model chosen → level falls back to the slot's - const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong); - assert.ok(partial); - assert.equal(partial!.thinkingLevel, "low"); - // thinkingLevel-only record: model untouched - assert.deepEqual(catalogDefaultsFor({ thinkingLevel: "minimal" }, strong), { - model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, - thinkingLevel: "minimal", - }); - // chat.model with NO strong slot: the conversation runs on it alone - assert.deepEqual( - catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, null), - { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, - ); + // No level stored → the model alone reports (the level resolves at + // engine build from DEFAULT_THINKING_ORDER). + const bare = catalogDefaultsFor({ provider: "openrouter", id: "qwen/qwen3.8-flash" }); + assert.deepEqual(bare, { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }); }); it("toModelDto projects the raw catalog model verbatim", () => { diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index e47050e..c8141be 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -5,7 +5,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { DraftShell, startDraftSession } from "../components/new-session.tsx"; -import type { ChatSettings } from "../src/core/index.ts"; /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" @@ -28,7 +27,7 @@ function fakeFetch(id: string) { describe("startDraftSession — the create-on-send contract", () => { it("creates the session titled with the first message, returns the prompt redirect", async () => { const { f, calls } = fakeFetch("c_new"); - const target = await startDraftSession("teach me orbital mechanics", undefined, f); + const target = await startDraftSession("teach me orbital mechanics", f); assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics"); assert.equal(calls.length, 1); assert.equal(calls[0]!.url, "/api/chats"); @@ -36,26 +35,16 @@ describe("startDraftSession — the create-on-send contract", () => { assert.deepEqual(calls[0]!.body, { title: "teach me orbital mechanics" }); }); - it("persists the lifted pre-chat settings after creation", async () => { - const { f, calls } = fakeFetch("c_2"); - const settings: ChatSettings = { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "medium" }; - await startDraftSession("hello", settings, f); - assert.equal(calls.length, 2); - assert.equal(calls[1]!.url, "/api/chats/c_2/settings"); - assert.equal(calls[1]!.method, "PUT"); - assert.deepEqual(calls[1]!.body, settings); - }); - - it("no settings lift → no settings write (a single create call)", async () => { + it("no settings PUT on create — the server stamps the newborn (per-session models, owner 2026-09-28)", async () => { const { f, calls } = fakeFetch("c_3"); - await startDraftSession("hello", undefined, f); - assert.equal(calls.length, 1); + await startDraftSession("hello", f); + assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side"); }); it("empty or whitespace text never creates anything", async () => { const { f, calls } = fakeFetch("never"); - assert.equal(await startDraftSession("", undefined, f), null); - assert.equal(await startDraftSession(" ", undefined, f), null); + assert.equal(await startDraftSession("", f), null); + assert.equal(await startDraftSession(" ", f), null); assert.equal(calls.length, 0); }); }); @@ -68,14 +57,12 @@ describe("DraftShell — the sessionless surface", () => { text="" onChange={() => {}} onSend={() => {}} - settings={undefined} - onSettingsSaved={() => {}} />, ); // The chat composer, ready for the first message. assert.ok(html.includes('data-testid="composer"')); - // The pre-chat model control (fix wave: the strong-slot control — - // app-wide, no per-chat write, no empty-chatId defer). + // The draft's model control binds the strong slot — the + // new-session default (per-session models, owner 2026-09-28). assert.ok(html.includes('data-testid="stats-model"')); // No CTA by default: a configured surface stays quiet. assert.ok(!html.includes('data-testid="setup-cta"')); @@ -94,8 +81,6 @@ describe("DraftShell — the sessionless surface", () => { text="" onChange={() => {}} onSend={() => {}} - settings={undefined} - onSettingsSaved={() => {}} setupCta={