From d9f47051d5f6a4637ef5a73efda2a9a51a183845 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 7 Sep 2026 18:33:02 -0700 Subject: [PATCH] [eric] models: the per-session heal never moves a chat onto the free Haiku row; a dead login left only that row reachable and three chats were rewritten onto it (install 59a37510) Co-Authored-By: Claude Fable 5.1 --- frontend/src/app/Main.tsx | 11 ++++++++- frontend/src/app/modelHealTarget.test.ts | 30 ++++++++++++++++++++++++ frontend/src/app/modelHealTarget.ts | 16 +++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/modelHealTarget.test.ts create mode 100644 frontend/src/app/modelHealTarget.ts diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 35f62a9f..4d09d392 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -12,6 +12,7 @@ import { fetchSettings, updateSettingsPatch, markFreeTrialArmSettled } from '@/s import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; import { updateSessionModel, persistSessionModel } from '@/shared/state/agentsSlice'; +import { healTarget } from './modelHealTarget'; import { API_BASE } from '@/shared/config'; import { setAppVersion, @@ -302,7 +303,15 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } return; } const known = new Set(knownValues); - const target = valid.has(settings.default_model) ? settings.default_model : fallback.value; + const p_target = healTarget(flat, settings.default_model, fallback); + if (!p_target) { + if (!warnedSessionsRef.current.has('free-row-only')) { + warnedSessionsRef.current.add('free-row-only'); + console.warn('[models] only the free row is reachable; chats on retired models stay where they are this tick'); + } + return; + } + const target = p_target.value; let switched = false; for (const sess of Object.values(store.getState().agents.sessions)) { if (sess.model && !known.has(sess.model)) { diff --git a/frontend/src/app/modelHealTarget.test.ts b/frontend/src/app/modelHealTarget.test.ts new file mode 100644 index 00000000..772dd5b3 --- /dev/null +++ b/frontend/src/app/modelHealTarget.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { healTarget } from './modelHealTarget'; + +const sonnet = { value: 'sonnet-5-cc', label: 'Claude Sonnet 5', billing_kind: 'subscription' }; +const haiku = { value: 'haiku', label: 'Claude Haiku 4.5', billing_kind: 'free' }; + +test('the stored default wins when it is reachable', () => { + assert.deepEqual(healTarget([sonnet, haiku], 'sonnet-5-cc', { value: 'x', label: 'X' }), { value: 'sonnet-5-cc', label: 'Claude Sonnet 5' }); +}); + +test('otherwise the priority fallback', () => { + assert.deepEqual(healTarget([sonnet], 'gpt-5.4', { value: 'sonnet-5-cc', label: 'Claude Sonnet 5' }), { value: 'sonnet-5-cc', label: 'Claude Sonnet 5' }); +}); + +test('when only the free row is reachable nothing is moved (the 1.7.9 Haiku rewrite)', () => { + assert.equal(healTarget([haiku], 'opus-5', { value: 'haiku', label: 'Claude Haiku 4.5' }), null); + assert.equal(healTarget([], 'opus-5', null), null); +}); + +test('the per-session heal in Main.tsx decides its target through healTarget', async () => { + const fs = await import('node:fs'); + const url = await import('node:url'); + const here = url.fileURLToPath(new URL('.', import.meta.url)); + const candidates = [here + 'Main.tsx', here.replace(/([\\/])\.test-build([\\/])/, '$1src$2') + 'Main.tsx']; + const path = candidates.find((p) => fs.existsSync(p)); + assert.ok(path, `could not locate Main.tsx from ${here}`); + const src = fs.readFileSync(path as string, 'utf8'); + assert.ok(src.includes('healTarget(flat, settings.default_model, fallback)'), 'the heal must ask healTarget, not pick a fallback on its own'); +}); diff --git a/frontend/src/app/modelHealTarget.ts b/frontend/src/app/modelHealTarget.ts new file mode 100644 index 00000000..29058910 --- /dev/null +++ b/frontend/src/app/modelHealTarget.ts @@ -0,0 +1,16 @@ +export interface HealModel { value: string; label: string; billing_kind?: string } + +/** Where a chat pinned to a retired model is moved, or null when nothing of the user's own is reachable + * this tick. On 1.7.9 a dead Claude login left only the funded Haiku row in the list and every chat was + * rewritten onto it (install 59a37510, 2026-09-03: three chats, then "No credentials for provider: claude" + * on a model nobody picked). A free row is a face for the picker, never a home for someone's chat. */ +export function healTarget( + reachable: HealModel[], + defaultModel: string, + fallback: { value: string; label: string } | null, +): { value: string; label: string } | null { + if (!reachable.some((m) => m.billing_kind !== 'free')) return null; + const current = reachable.find((m) => m.value === defaultModel); + if (current) return { value: current.value, label: current.label }; + return fallback; +}