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
-1
View File
@@ -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
View File
@@ -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