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:
+3
-2
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
+16
-14
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user