Keep unresolved session identity separate from Strong fallback

This commit is contained in:
amosblomqvist
2026-10-27 16:21:54 +02:00
parent 419dcf1ba2
commit 06714e5998
2 changed files with 48 additions and 11 deletions
+15 -11
View File
@@ -43,7 +43,9 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
// A successful read with no settings is a legacy session; undefined
// means no session record is known yet, not permission to inherit Strong.
const [session, setSession] = useState<{ settings?: ChatSettings }>();
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -58,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
const readRecord = useCallback(() => {
if (chatId) {
fetch(`/api/chats/${chatId}`)
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>)
.then((body) => setSession(body.settings))
.then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
.then((body) => setSession({ settings: body.settings }))
.catch(() => {
/* same read-only posture as a models-section failure */
});
@@ -83,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
});
// Dual-bound truth: chat mode displays the session's own record,
// falling back to the strong slot for legacy unstamped sessions —
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong
// slot itself.
// falling back to Strong only for a successfully read legacy unstamped
// session — EXACTLY what resolveConversationSpec runs. An unresolved or
// failed record read must not report a provisional global preference as
// this session's identity (including to the Send guard). Draft mode is
// the strong slot itself.
const strong = models?.strong;
const override: ChatSettings | undefined = chatId
? {
model: session?.model ?? strong?.model,
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel,
}
? session ? {
model: session.settings?.model ?? strong?.model,
thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
} : undefined
: strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
@@ -195,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
return;
}
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
setSession(stored?.settings ?? next);
setSession({ settings: stored?.settings ?? next });
setOpen(null);
} catch {
setError("Could not reach the server — try again.");
+33
View File
@@ -233,6 +233,39 @@ test("blocked session recovery opens its ordinary picker, never changes Strong o
assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong });
});
for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
// Global Strong is unavailable, but this existing session owns a valid
// model. Resolve global settings first while keeping its record pending.
const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } };
let resolveSession!: (response: Response) => void;
let rejectSession!: (reason: Error) => void;
const sessionRead = new Promise<Response>((resolve, reject) => { resolveSession = resolve; rejectSession = reject; });
const requestsOnly = globalThis.fetch;
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return sessionRead;
return requestsOnly(input, init);
};
await warm(body); const identities: Array<string | undefined> = [];
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { identities.push(id); }} /></>);
assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case");
assert.equal(element("stats-model").textContent, "—▾");
assert.equal(identities.at(-1), undefined);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong");
await act(async () => {
if (result === "network-failure") rejectSession(new Error("fixture connection failure"));
else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 }));
else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } }));
});
const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined;
assert.equal(identities.at(-1), expected);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin");
assert.equal(document.querySelector('[data-testid="model-menu"]'), null);
assert.deepEqual(writes, []);
});
test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => {
const body = catalog(false);
assert.equal(savedSlotsConfigured(body.slots), true);