per-session models: every session owns its model + thinking level from birth

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.
This commit is contained in:
amosblomqvist
2026-09-28 23:30:11 +03:00
parent 1f5e21a674
commit 3e9a85d60f
27 changed files with 336 additions and 491 deletions
-36
View File
@@ -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 });
}
+5 -33
View File
@@ -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,