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
+3 -2
View File
@@ -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). */
-4
View File
@@ -68,9 +68,6 @@ export type EffectiveAppearance = FactorySettings["appearance"];
/** The concrete view: every knob resolved, no absent fields. */
export interface EffectiveSettings {
general: Required<GeneralSettings>;
/** The default ChatSettings for new sessions — passthrough delta
* ({} = no default = BYOK "no model" first boot). */
chat: ChatSettings;
agent: {
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
/** 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 ?? {},
+6 -11
View File
@@ -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<string, unknown>);
}
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<string, unknown>);
}
@@ -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<string, unknown>);
}
// 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;