[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 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-07 18:33:02 -07:00
co-authored by Claude Fable 5.1
parent fc7d063206
commit d9f47051d5
3 changed files with 56 additions and 1 deletions
+10 -1
View File
@@ -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)) {
+30
View File
@@ -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');
});
+16
View File
@@ -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;
}