From 47828c860acd9b21dac9ca0e009fd8bac20a6536 Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Sun, 30 Aug 2026 12:26:32 +0000 Subject: [PATCH 1/7] fix: stop re-downloading and re-parsing the full transcript on every watcher tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An actively-working session's transcript grows continuously, and the filesystem watcher fires a `session_upserted` event on every change while it's the currently-viewed session. The client reacted to that by calling refreshFromServer(), which fetched the entire, unbounded message history on every single tick — and the server's getSessionMessages() re-read and re-JSON.parsed the whole JSONL file (plus every referenced subagent agent-*.jsonl file) from scratch each time, regardless of the caller's requested limit. Together this meant network transfer and CPU cost per tick scaled with total session size instead of with how much actually changed, turning a long-running session into a continuous multi-MB refetch loop. - refreshFromServer now requests a bounded tail window (TAIL_REFRESH_LIMIT) and merges only genuinely-new messages by id, falling back to a full fetch only if more messages appeared in one tick than the window covers. - getSessionMessages/parseAgentTools now cache parsed JSONL lines per file (keyed by size+mtime) and read only newly-appended bytes on repeat calls, instead of re-reading and re-parsing from byte 0 every time. Co-Authored-By: Claude Sonnet 5 --- .../list/claude/claude-sessions.provider.ts | 186 +++++++++++------- .../claude-sessions-history-cache.test.ts | 150 ++++++++++++++ src/stores/useSessionStore.ts | 99 ++++++++-- 3 files changed, 356 insertions(+), 79 deletions(-) create mode 100644 server/modules/providers/tests/claude-sessions-history-cache.test.ts diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 8cdf44fa97..f9881d7c7b 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; -import readline from 'node:readline'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; @@ -10,6 +9,94 @@ import { sessionsDb } from '@/modules/database/index.js'; const PROVIDER = 'claude'; +// ─── Incremental, cached JSONL line reads ─────────────────────────────────── +// +// getSessionMessages() is invoked on every `/messages` request regardless of +// the caller's requested limit (fetchHistory always loads full raw history +// first — see below), including on every filesystem-watcher-triggered +// refresh while a session is actively being written to. Re-reading and +// re-JSON.parse-ing the entire transcript (and every referenced subagent +// file) from byte 0 on each of those calls scales with total session size, +// not with how much actually changed since the last call. These caches let +// repeat calls reuse already-parsed lines and only read the bytes appended +// since the last read. + +type LineCacheEntry = { + size: number; + mtimeMs: number; + lines: AnyRecord[]; + trailingPartialLine: string; +}; + +const LINE_CACHE_MAX_ENTRIES = 100; +const lineCache = new Map(); + +function touchLineCache(filePath: string, entry: LineCacheEntry): void { + // Map preserves insertion order; delete-then-set moves `filePath` to the + // most-recently-used end so eviction below drops the least-recently-used. + lineCache.delete(filePath); + lineCache.set(filePath, entry); + while (lineCache.size > LINE_CACHE_MAX_ENTRIES) { + const oldestKey = lineCache.keys().next().value; + if (oldestKey === undefined) break; + lineCache.delete(oldestKey); + } +} + +/** + * Returns every parsed JSON line of `filePath`, reusing a cached parse when + * the file hasn't changed and reading only the newly-appended bytes when it + * has grown. Falls back to a full re-read when the file shrank (rotated/ + * truncated/rewritten) — not expected for Claude's append-only transcripts, + * but a stale, wrong cache is worse than an occasional extra full read. + */ +async function getCachedLines(filePath: string): Promise { + let stat: { size: number; mtimeMs: number }; + try { + stat = await fsp.stat(filePath); + } catch { + return []; + } + + const cached = lineCache.get(filePath); + if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) { + return cached.lines; + } + + const canAppend = Boolean(cached) && stat.size > cached!.size; + const readStart = canAppend ? cached!.size : 0; + const lines: AnyRecord[] = canAppend ? cached!.lines : []; + let buffer = canAppend ? cached!.trailingPartialLine : ''; + + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath, { start: readStart, encoding: 'utf8' }); + stream.on('data', (chunk: string | Buffer) => { + buffer += chunk; + const parts = buffer.split('\n'); + buffer = parts.pop() ?? ''; + for (const part of parts) { + if (!part.trim()) continue; + try { + lines.push(JSON.parse(part) as AnyRecord); + } catch { + // Skip malformed JSONL lines that can happen during concurrent writes. + } + } + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + touchLineCache(filePath, { + size: stat.size, + mtimeMs: stat.mtimeMs, + lines, + trailingPartialLine: buffer, + }); + + return lines; +} + type ClaudeToolResult = { content: unknown; isError: boolean; @@ -39,58 +126,44 @@ async function parseAgentTools(filePath: string): Promise { const tools: AnyRecord[] = []; try { - const fileStream = fs.createReadStream(filePath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - if (!line.trim()) { - continue; - } + const entries = await getCachedLines(filePath); - try { - const entry = JSON.parse(line) as AnyRecord; - - if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { - for (const part of entry.message.content as AnyRecord[]) { - if (part.type === 'tool_use') { - tools.push({ - toolId: part.id, - toolName: part.name, - toolInput: part.input, - timestamp: entry.timestamp, - }); - } + for (const entry of entries) { + if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { + for (const part of entry.message.content as AnyRecord[]) { + if (part.type === 'tool_use') { + tools.push({ + toolId: part.id, + toolName: part.name, + toolInput: part.input, + timestamp: entry.timestamp, + }); } } + } - if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { - for (const part of entry.message.content as AnyRecord[]) { - if (part.type !== 'tool_result') { - continue; - } + if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { + for (const part of entry.message.content as AnyRecord[]) { + if (part.type !== 'tool_result') { + continue; + } - const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id); - if (!tool) { - continue; - } + const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id); + if (!tool) { + continue; + } - tool.toolResult = { - content: typeof part.content === 'string' + tool.toolResult = { + content: typeof part.content === 'string' + ? part.content + : Array.isArray(part.content) ? part.content - : Array.isArray(part.content) - ? part.content - .map((contentPart: AnyRecord) => contentPart?.text || '') - .join('\n') - : JSON.stringify(part.content), - isError: Boolean(part.is_error), - }; - } + .map((contentPart: AnyRecord) => contentPart?.text || '') + .join('\n') + : JSON.stringify(part.content), + isError: Boolean(part.is_error), + }; } - } catch { - // Skip malformed lines that can happen during concurrent writes. } } } catch (error) { @@ -120,29 +193,10 @@ async function getSessionMessages( const files = await fsp.readdir(projectDir); const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-')); - const messages: AnyRecord[] = []; const agentToolsCache = new Map(); - const fileStream = fs.createReadStream(jsonLPath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - if (!line.trim()) { - continue; - } - - try { - const entry = JSON.parse(line) as AnyRecord; - if (entry.sessionId === providerSessionId) { - messages.push(entry); - } - } catch { - // Skip malformed JSONL lines that can happen during concurrent writes. - } - } + const allEntries = await getCachedLines(jsonLPath); + const messages = allEntries.filter((entry) => entry.sessionId === providerSessionId); const agentIds = new Set(); for (const message of messages) { diff --git a/server/modules/providers/tests/claude-sessions-history-cache.test.ts b/server/modules/providers/tests/claude-sessions-history-cache.test.ts new file mode 100644 index 0000000000..773a3113c2 --- /dev/null +++ b/server/modules/providers/tests/claude-sessions-history-cache.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; + +/** + * getSessionMessages() (private to claude-sessions.provider.ts) caches parsed + * JSONL lines per file and reads only newly-appended bytes on repeat calls — + * this exercises that behavior end-to-end through the public fetchHistory() + * API, across a growing transcript and its subagent file, since the cache + * itself isn't exported for direct unit testing. + */ +async function withIsolatedDatabase(runTest: () => Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'claude-history-cache-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +function jsonl(entries: unknown[]): string { + return entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n'; +} + +test('fetchHistory reuses cached lines and picks up only appended bytes as the transcript grows', async () => { + await withIsolatedDatabase(async () => { + const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-project-')); + const jsonlPath = path.join(projectDir, 'sess-1.jsonl'); + const agentPath = path.join(projectDir, 'agent-agent-1.jsonl'); + + await writeFile(jsonlPath, jsonl([ + { + sessionId: 'sess-1', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'message 1' }] }, + }, + { + sessionId: 'sess-1', + uuid: 'u2', + timestamp: '2026-01-01T00:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'reply 1' }] }, + }, + { + sessionId: 'sess-1', + uuid: 'u3', + timestamp: '2026-01-01T00:00:02.000Z', + toolUseResult: { agentId: 'agent-1' }, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolid1', content: 'ok' }], + }, + }, + ])); + + await writeFile(agentPath, jsonl([ + { + timestamp: '2026-01-01T00:00:00.500Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'sub-tool-1', name: 'Bash', input: { command: 'ls' } }], + }, + }, + ])); + + sessionsDb.createSession('sess-1', 'claude', projectDir, undefined, undefined, undefined, jsonlPath); + + const provider = new ClaudeSessionsProvider(); + + const first = await provider.fetchHistory('sess-1', {}); + assert.equal(first.total, 2, 'text/reply messages count; the tool_result entry does not'); + const firstToolResult = first.messages.find((m) => m.kind === 'tool_result'); + assert.ok(firstToolResult, 'tool_result message should be present'); + assert.deepEqual( + (firstToolResult!.subagentTools as Array<{ toolName: string }>).map((t) => t.toolName), + ['Bash'], + ); + + // Simulate the agent continuing to work: append a new top-level message + // and a new subagent tool call, rather than rewriting the files. + await writeFile( + jsonlPath, + jsonl([{ + sessionId: 'sess-1', + uuid: 'u4', + timestamp: '2026-01-01T00:00:03.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'message 2' }] }, + }]), + { flag: 'a' }, + ); + await writeFile( + agentPath, + jsonl([{ + timestamp: '2026-01-01T00:00:01.500Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'sub-tool-2', name: 'Read', input: { path: 'a.txt' } }], + }, + }]), + { flag: 'a' }, + ); + + const second = await provider.fetchHistory('sess-1', {}); + assert.equal(second.total, 3, 'exactly one new top-level message appeared — no duplicates from re-reading old bytes'); + const secondToolResult = second.messages.find((m) => m.kind === 'tool_result'); + assert.deepEqual( + (secondToolResult!.subagentTools as Array<{ toolName: string }>).map((t) => t.toolName).sort(), + ['Bash', 'Read'], + 'the subagent file cache picked up the appended tool call alongside the original one', + ); + + // No changes since the last call: repeat calls must be idempotent. + const third = await provider.fetchHistory('sess-1', {}); + assert.equal(third.total, 3); + + // The file shrinking/being rewritten (not expected for Claude's + // append-only transcripts, but defended against) must not leave stale + // cached lines mixed in with the new content. + await writeFile(jsonlPath, jsonl([{ + sessionId: 'sess-1', + uuid: 'u5', + timestamp: '2026-01-01T00:00:04.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'reset' }] }, + }])); + + const fourth = await provider.fetchHistory('sess-1', {}); + assert.equal(fourth.total, 1, 'a shrunk/rewritten file falls back to a full re-read instead of reusing stale cached lines'); + assert.equal(fourth.messages[0]?.content, 'reset'); + + await rm(projectDir, { recursive: true, force: true }); + }); +}); diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 6117cbb7b3..eeea93afa5 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -425,6 +425,36 @@ const STALE_THRESHOLD_MS = 30_000; const MAX_REALTIME_MESSAGES = 500; +/** + * `refreshFromServer` is triggered by a filesystem-watcher event every time + * the session's transcript changes on disk (e.g. an agent actively working + * appends to it). Requesting the newest TAIL_REFRESH_LIMIT messages instead + * of the entire transcript keeps that request cheap regardless of how large + * the transcript has grown. Sized generously above what a single turn's + * worth of tool calls should ever append between two debounced watcher + * ticks; if the transcript grew by more than this in one tick anyway (rare), + * `refreshFromServer` falls back to a full fetch for that one call. + */ +const TAIL_REFRESH_LIMIT = 200; + +/** + * Append only the messages from `tail` that aren't already present (by id) + * in `existing`, preserving `existing`'s earlier history untouched. Used to + * apply a bounded tail-window response without discarding older pages + * `fetchMore` already loaded. + */ +function mergeTailMessages( + existing: NormalizedMessage[], + tail: NormalizedMessage[], +): NormalizedMessage[] { + const existingIds = new Set(existing.map((message) => message.id)); + const newMessages = tail.filter((message) => !existingIds.has(message.id)); + if (newMessages.length === 0) { + return existing; + } + return [...existing, ...newMessages]; +} + // ─── Hook ──────────────────────────────────────────────────────────────────── export function useSessionStore() { @@ -613,19 +643,66 @@ export function useSessionStore() { /** * Re-fetch serverMessages from the provider sessions endpoint. + * + * Requests only the newest TAIL_REFRESH_LIMIT messages rather than the + * entire transcript — this is called on every watcher-detected disk + * change, which for an actively-working session can be every couple of + * seconds, and re-downloading (and server-side re-parsing) the whole + * transcript on each of those ticks scales with total session size + * instead of with how much actually changed. Falls back to a full fetch + * only when the tail window provably missed something (more messages + * appeared since the last fetch than the window covers). */ const refreshFromServer = useCallback(async ( sessionId: string, ) => { const slot = getSlot(sessionId); const fetchTicket = ++slot._fetchSeq; + const knownTotal = slot.total; + + const applyServerSnapshot = (data: { messages?: NormalizedMessage[]; total?: number; hasMore?: boolean }) => { + slot.serverMessages = data.messages || []; + slot.total = data.total ?? slot.serverMessages.length; + slot.hasMore = Boolean(data.hasMore); + slot.fetchedAt = Date.now(); + // Only drop realtime rows the server transcript now owns. A blind clear + // here caused the chat pane to flash "Continue your conversation" after + // `complete` while JSONL / provider_session_id indexing was still behind. + slot.realtimeMessages = pruneRealtimeSupersededByServer( + slot.serverMessages, + slot.realtimeMessages, + ); + recomputeMergedIfNeeded(slot); + }; + try { - const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`; + const params = new URLSearchParams(); + params.append('limit', String(TAIL_REFRESH_LIMIT)); + params.append('offset', '0'); + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`; const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const body = await response.json(); const data = body?.data ?? body; + const newTotal = data.total ?? 0; + + // The tail window didn't cover everything appended since the last + // fetch (rare — would need >TAIL_REFRESH_LIMIT new messages inside one + // debounce tick): fall back to the unbounded history exactly once. + if (knownTotal > 0 && newTotal - knownTotal > TAIL_REFRESH_LIMIT) { + const fullUrl = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`; + const fullResponse = await authenticatedFetch(fullUrl); + if (!fullResponse.ok) throw new Error(`HTTP ${fullResponse.status}`); + const fullBody = await fullResponse.json(); + const fullData = fullBody?.data ?? fullBody; + + if (fetchTicket <= slot._appliedFetchSeq) return; + slot._appliedFetchSeq = fetchTicket; + applyServerSnapshot(fullData); + notify(sessionId); + return; + } // A later-started fetch already applied: applying this stale transcript // would erase rows the user has already seen (and re-prune realtime @@ -635,18 +712,14 @@ export function useSessionStore() { } slot._appliedFetchSeq = fetchTicket; - slot.serverMessages = data.messages || []; - slot.total = data.total ?? slot.serverMessages.length; - slot.hasMore = Boolean(data.hasMore); - slot.fetchedAt = Date.now(); - // Only drop realtime rows the server transcript now owns. A blind clear - // here caused the chat pane to flash "Continue your conversation" after - // `complete` while JSONL / provider_session_id indexing was still behind. - slot.realtimeMessages = pruneRealtimeSupersededByServer( - slot.serverMessages, - slot.realtimeMessages, - ); - recomputeMergedIfNeeded(slot); + applyServerSnapshot({ + ...data, + messages: mergeTailMessages(slot.serverMessages, data.messages || []), + // The tail request's own `hasMore` reflects pagination within the + // tail window, not whether older history (already loaded via + // fetchMore) is still available — preserve what we already know. + hasMore: slot.hasMore, + }); notify(sessionId); } catch (error) { console.error(`[SessionStore] refresh failed for ${sessionId}:`, error); From f7e7d7b4a18e1200989ec32e6201958ed7fb5284 Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Sun, 30 Aug 2026 12:58:58 +0000 Subject: [PATCH 2/7] fix: restore chronological order when merging tail-window refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeTailMessages (introduced in 47828c8) appended not-yet-seen tail messages to the END of the already-loaded array, assuming they were always newer. That's false whenever the loaded page is smaller than the tail window: refreshFromServer's tail request commonly reaches further back than fetchFromServer's initial page (e.g. any session small enough that its whole history fits inside TAIL_REFRESH_LIMIT), so the "new" messages it surfaces are actually OLDER than what's already showing, not newer — they were landing after the newest messages instead of before them. Symptoms in production: messages rendering out of order and reshuffling on every watcher tick ("jumping around"), assistant replies whose content the model itself referenced not appearing where expected, and the chat only looking right immediately after sending a message (the one path that still does a full, correctly-ordered reload). mergeTailMessages now merges by id (tail's copy wins on overlap, so content that updates after the fact — e.g. a tool_result's subagentTools gaining entries — isn't stuck stale) and re-sorts the result chronologically instead of trusting concatenation order. The pure ordering/merge logic (readMessageTime, compareMessagesChronologically, mergeTailMessages) is split into sessionMessageOrdering.ts specifically so it can be covered by a real test — this project has no frontend test runner, and useSessionStore.ts itself can't be imported outside Vite (import.meta.env). The new test reproduces the exact bug shape (a 2-message loaded page vs. a 5-message tail window) and is confirmed to fail against the old concatenation-only logic before this fix. Co-Authored-By: Claude Sonnet 5 --- src/stores/sessionMessageOrdering.ts | 49 +++++++++++++++ .../tests/sessionMessageOrdering.test.ts | 63 +++++++++++++++++++ src/stores/useSessionStore.ts | 34 +--------- 3 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 src/stores/sessionMessageOrdering.ts create mode 100644 src/stores/tests/sessionMessageOrdering.test.ts diff --git a/src/stores/sessionMessageOrdering.ts b/src/stores/sessionMessageOrdering.ts new file mode 100644 index 0000000000..d88024c94d --- /dev/null +++ b/src/stores/sessionMessageOrdering.ts @@ -0,0 +1,49 @@ +import type { NormalizedMessage } from './useSessionStore'; + +/** + * Pure message-ordering/merge helpers, split out from useSessionStore.ts so + * they're importable from a plain Node test (no React, no Vite `import.meta.env`) + * — this is exactly the logic a client-side ordering bug lives in, and it needs + * real coverage independent of the rest of the store's React wiring. + */ + +export function readMessageTime(m: NormalizedMessage): number | null { + const time = Date.parse(m.timestamp); + return Number.isFinite(time) ? time : null; +} + +export function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number { + const timeA = readMessageTime(a) ?? 0; + const timeB = readMessageTime(b) ?? 0; + if (timeA !== timeB) { + return timeA - timeB; + } + return 0; +} + +/** + * Merge a bounded tail-window response into `existing` by id, letting the + * tail's copy win where both have a message (it can carry updates existing + * doesn't have yet, e.g. a tool_result's subagentTools gaining entries as a + * subagent keeps working), then re-sort chronologically. + * + * The sort is required, not cosmetic: `existing` is a suffix (the initially + * loaded page, or an earlier tail merge) and `tail` (the newest + * TAIL_REFRESH_LIMIT messages) commonly reaches further back than that + * suffix once the transcript is small enough that the whole thing fits in + * one tail window — naively appending tail's not-yet-seen messages after + * `existing` would place older messages after newer ones. + */ +export function mergeTailMessages( + existing: NormalizedMessage[], + tail: NormalizedMessage[], +): NormalizedMessage[] { + const byId = new Map(); + for (const message of existing) { + byId.set(message.id, message); + } + for (const message of tail) { + byId.set(message.id, message); + } + return Array.from(byId.values()).sort(compareMessagesChronologically); +} diff --git a/src/stores/tests/sessionMessageOrdering.test.ts b/src/stores/tests/sessionMessageOrdering.test.ts new file mode 100644 index 0000000000..5eb149692c --- /dev/null +++ b/src/stores/tests/sessionMessageOrdering.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { mergeTailMessages } from '../sessionMessageOrdering'; +import type { NormalizedMessage } from '../useSessionStore'; + +function msg(id: string, minute: number, extra: Partial = {}): NormalizedMessage { + return { + id, + sessionId: 'sess-1', + timestamp: `2026-01-01T00:${String(minute).padStart(2, '0')}:00.000Z`, + provider: 'claude', + kind: 'text', + role: 'user', + content: id, + ...extra, + }; +} + +test('mergeTailMessages inserts tail messages that predate the already-loaded page in chronological order', () => { + // Reproduces the production bug: fetchFromServer's initial page only holds + // the newest couple of messages (a small session, or MESSAGES_PER_PAGE cutting + // it short), while refreshFromServer's tail window (TAIL_REFRESH_LIMIT) reaches + // further back and includes messages `existing` hasn't seen yet — those are + // OLDER than what's already loaded, not newer, and must not land at the end. + const existing = [msg('m4', 4), msg('m5', 5)]; + const tail = [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4), msg('m5', 5)]; + + const merged = mergeTailMessages(existing, tail); + + assert.deepEqual(merged.map((m) => m.id), ['m1', 'm2', 'm3', 'm4', 'm5']); +}); + +test('mergeTailMessages lets the tail copy overwrite stale content for an id both sides share', () => { + const existing = [msg('m1', 1, { subagentTools: [{ toolName: 'Bash' }] })]; + const tail = [msg('m1', 1, { subagentTools: [{ toolName: 'Bash' }, { toolName: 'Read' }] })]; + + const merged = mergeTailMessages(existing, tail); + + assert.equal(merged.length, 1); + assert.deepEqual( + (merged[0].subagentTools as Array<{ toolName: string }>).map((t) => t.toolName), + ['Bash', 'Read'], + ); +}); + +test('mergeTailMessages is idempotent when nothing new appeared', () => { + const existing = [msg('m1', 1), msg('m2', 2)]; + const tail = [msg('m1', 1), msg('m2', 2)]; + + const merged = mergeTailMessages(existing, tail); + + assert.deepEqual(merged.map((m) => m.id), ['m1', 'm2']); +}); + +test('mergeTailMessages appends genuinely new, newer-than-everything-loaded messages at the end', () => { + const existing = [msg('m1', 1), msg('m2', 2)]; + const tail = [msg('m1', 1), msg('m2', 2), msg('m3', 3)]; + + const merged = mergeTailMessages(existing, tail); + + assert.deepEqual(merged.map((m) => m.id), ['m1', 'm2', 'm3']); +}); diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index eeea93afa5..18426664f8 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -12,6 +12,8 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { authenticatedFetch } from '../utils/api'; import type { LLMProvider } from '../types/app'; +import { compareMessagesChronologically, mergeTailMessages, readMessageTime } from './sessionMessageOrdering'; + // ─── NormalizedMessage (mirrors server/adapters/types.js) ──────────────────── export type MessageKind = @@ -149,11 +151,6 @@ function userTextFingerprint(m: NormalizedMessage): string | null { return t.length > 0 ? t : null; } -function readMessageTime(m: NormalizedMessage): number | null { - const time = Date.parse(m.timestamp); - return Number.isFinite(time) ? time : null; -} - function hasServerEchoForLocalUser( localMessage: NormalizedMessage, serverMessages: NormalizedMessage[], @@ -178,15 +175,6 @@ function hasServerEchoForLocalUser( }); } -function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number { - const timeA = readMessageTime(a) ?? 0; - const timeB = readMessageTime(b) ?? 0; - if (timeA !== timeB) { - return timeA - timeB; - } - return 0; -} - /** * Count how many user turns precede `message` in a chronologically merged view * of server + realtime rows. Used to match a realtime row to the correct turn @@ -437,24 +425,6 @@ const MAX_REALTIME_MESSAGES = 500; */ const TAIL_REFRESH_LIMIT = 200; -/** - * Append only the messages from `tail` that aren't already present (by id) - * in `existing`, preserving `existing`'s earlier history untouched. Used to - * apply a bounded tail-window response without discarding older pages - * `fetchMore` already loaded. - */ -function mergeTailMessages( - existing: NormalizedMessage[], - tail: NormalizedMessage[], -): NormalizedMessage[] { - const existingIds = new Set(existing.map((message) => message.id)); - const newMessages = tail.filter((message) => !existingIds.has(message.id)); - if (newMessages.length === 0) { - return existing; - } - return [...existing, ...newMessages]; -} - // ─── Hook ──────────────────────────────────────────────────────────────────── export function useSessionStore() { From 1c2a605fff18f566ef2c560669b4d3e57526069d Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Sat, 5 Sep 2026 14:37:49 +0000 Subject: [PATCH 3/7] fix: default new sessions to Fable instead of Sonnet on cloud-admin-box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cloud-admin-box's 2026-09-03 model pin only patched ~/.claude/settings.json, which the interactive `claude` CLI reads but this app's own SDK-driven sessions never consult (claude-sdk.js always passes an explicit `model` option, which overrides settingSources resolution). CLAUDE_FALLBACK_MODELS.DEFAULT was the literal string 'default', which this file's own OPTIONS list defines as "the Claude Code default model (currently Sonnet 4.6)" — independent of any settings.json. Point the fallback at 'fable' directly so sessions with no explicit per-session model override get Fable, matching the operator's standing preference for this box. Co-Authored-By: Claude Sonnet 5 --- .../modules/providers/list/claude/claude-models.provider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index 2c80cc4034..4e00b888dc 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -109,7 +109,11 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok', }, ], - DEFAULT: 'default', + // divizend: this box's operator-standing default is Fable, not upstream's plain + // 'default' (~Sonnet) — see cloud-admin-box's CLAUDE.md model-pin note. The 'default' + // OPTION entry above is untouched, so explicitly picking "Default (recommended)" from + // the model picker still behaves as upstream intended. + DEFAULT: 'fable', }; export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => { From 1cffc44117950db0ae4c274da84345854cd0619a Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Sat, 5 Sep 2026 15:40:37 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20actually=20default=20to=20Fable=20?= =?UTF-8?q?=E2=80=94=20frontend=20always=20sent=20an=20explicit=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.36.2-divizend.3 changed the backend's CLAUDE_FALLBACK_MODELS.DEFAULT, but verification against a live cloud-admin-box session after deploying it showed sessions were still landing on Sonnet. Root cause: the chat websocket handler spreads the client's `data.options` verbatim into queryClaudeSDK's options (chat-websocket.service.ts), and the frontend's claudeModel state is *never* empty — it's either a cached localStorage['claude-model'] value or the hardcoded FALLBACK_DEFAULT_MODEL.claude ('default' literal). So `options.model || CLAUDE_FALLBACK_MODELS.DEFAULT` on the backend almost never reaches its fallback branch; the client's explicit 'default' wins every time, independent of that backend constant. Fixes, for real this time: - FALLBACK_DEFAULT_MODEL.claude (frontend) now matches CLAUDE_FALLBACK_MODELS. DEFAULT (backend): the authoritative pinned API model id 'claude-fable-5-1', not a generic 'fable' alias that could drift to a different snapshot later, and not the frontend's separate 'default' literal that bypassed the backend fallback entirely. - The Fable OPTIONS entry's `value` is renamed to match, so resolveClaudeEffort's lookup still matches and the default session keeps Fable's declared effort levels instead of silently losing effort resolution. - claudeModel's localStorage-backed init now goes through resolveInitialProviderModel (new src/stores/providerModelDefaults.ts), which treats an already-cached literal 'default' the same as "no real preference" and falls through to the new fallback — so browsers that loaded this app before today also pick up Fable on next load, not just brand-new ones. Pure function + Node-runnable test, following the precedent set by sessionMessageOrdering.ts after the last frontend-only regression shipped without coverage. Co-Authored-By: Claude Sonnet 5 --- .../list/claude/claude-models.provider.ts | 10 +++++-- server/routes/agent.js | 2 +- .../chat/hooks/useChatProviderState.ts | 8 +++-- src/stores/providerModelDefaults.ts | 30 +++++++++++++++++++ .../tests/providerModelDefaults.test.ts | 23 ++++++++++++++ 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 src/stores/providerModelDefaults.ts create mode 100644 src/stores/tests/providerModelDefaults.test.ts diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index 4e00b888dc..787535e869 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -31,9 +31,13 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { }, }, { - value: 'fable', + // divizend: the box's own operator-standing default (see cloud-admin-box's + // CLAUDE.md) pins the exact API model id, not a generic 'fable' alias that + // could silently drift to a different snapshot later — this value is also + // what CLAUDE_FALLBACK_MODELS.DEFAULT below points at. + value: 'claude-fable-5-1', label: 'Fable', - description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', + description: 'Fable 5.1 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', effort: { default: 'high', values: [ @@ -113,7 +117,7 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { // 'default' (~Sonnet) — see cloud-admin-box's CLAUDE.md model-pin note. The 'default' // OPTION entry above is untouched, so explicitly picking "Default (recommended)" from // the model picker still behaves as upstream intended. - DEFAULT: 'fable', + DEFAULT: 'claude-fable-5-1', }; export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => { diff --git a/server/routes/agent.js b/server/routes/agent.js index 1ae605b69e..6889896aa7 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -646,7 +646,7 @@ class ResponseCollector { * * @param {string} model - (Optional) Model identifier for providers. * - * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable' + * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'claude-fable-5-1' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 1d0aac5af5..df00bb767c 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -15,9 +15,13 @@ import { FALLBACK_PROVIDER_EFFORT_VALUES, toProviderEffortOptions, } from '../constants/providerEffort'; +import { resolveInitialProviderModel } from '../../../stores/providerModelDefaults'; const FALLBACK_DEFAULT_MODEL: Record = { - claude: 'default', + // divizend: this box's operator-standing default — the exact API model id + // pinned in cloud-admin-box's ~/.claude/settings.json, not a generic 'fable' + // alias that could silently drift to a different snapshot later. + claude: 'claude-fable-5-1', cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', @@ -95,7 +99,7 @@ export function useChatProviderState({ selectedSession, selectedProject: _select return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor; }); const [claudeModel, setClaudeModel] = useState(() => { - return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude; + return resolveInitialProviderModel(localStorage.getItem('claude-model'), FALLBACK_DEFAULT_MODEL.claude); }); const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; diff --git a/src/stores/providerModelDefaults.ts b/src/stores/providerModelDefaults.ts new file mode 100644 index 0000000000..e0debe5d84 --- /dev/null +++ b/src/stores/providerModelDefaults.ts @@ -0,0 +1,30 @@ +/** + * Pure model-default resolution, split out from useChatProviderState.ts so + * it's importable from a plain Node test (no React, no Vite `import.meta.env` + * / `localStorage`) — this is exactly the logic a stale-default regression + * lives in, and it needs real coverage independent of the rest of the hook's + * React/localStorage wiring. + */ + +/** + * Resolves the initial model value for a provider from a (possibly absent) + * localStorage-cached value. + * + * A stored value of literally 'default' is treated the same as "no real + * preference" rather than an explicit choice: it's what every browser that + * loaded this app before `fallback` pointed at a real model would have + * cached, purely as an artifact of the old fallback's own value, never a + * deliberate pick. Falling through to `fallback` here means a browser with + * that stale cache picks up a new fallback (e.g. this box's Fable pin) + * without any manual action, while any other stored value — an explicit + * pick of 'sonnet', 'opus', a real model id, etc. — is left untouched. + */ +export function resolveInitialProviderModel( + stored: string | null | undefined, + fallback: string, +): string { + if (stored && stored !== 'default') { + return stored; + } + return fallback; +} diff --git a/src/stores/tests/providerModelDefaults.test.ts b/src/stores/tests/providerModelDefaults.test.ts new file mode 100644 index 0000000000..dfdc9ee0e3 --- /dev/null +++ b/src/stores/tests/providerModelDefaults.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveInitialProviderModel } from '../providerModelDefaults'; + +test('resolveInitialProviderModel falls through to the fallback when nothing is stored', () => { + assert.equal(resolveInitialProviderModel(null, 'claude-fable-5-1'), 'claude-fable-5-1'); + assert.equal(resolveInitialProviderModel(undefined, 'claude-fable-5-1'), 'claude-fable-5-1'); + assert.equal(resolveInitialProviderModel('', 'claude-fable-5-1'), 'claude-fable-5-1'); +}); + +test('resolveInitialProviderModel upgrades an already-cached literal "default" to the new fallback', () => { + // Reproduces the production bug: a browser that loaded this app before the + // fallback pointed at a real model cached the string 'default' itself, and + // a plain `stored || fallback` never picks up a later fallback change for + // that browser again. + assert.equal(resolveInitialProviderModel('default', 'claude-fable-5-1'), 'claude-fable-5-1'); +}); + +test('resolveInitialProviderModel preserves an explicit prior model choice', () => { + assert.equal(resolveInitialProviderModel('sonnet', 'claude-fable-5-1'), 'sonnet'); + assert.equal(resolveInitialProviderModel('opus', 'claude-fable-5-1'), 'opus'); +}); From 3f51fbbb7843b13ad3440d17acfdb693d42feb7a Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Sat, 5 Sep 2026 18:20:31 +0000 Subject: [PATCH 5/7] fix: default back to Sonnet, and stop persisting fallbacks as picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable as the default (v1.36.2-divizend.4, earlier today) burned tokens far too fast; operator asked for Sonnet again the same day. A plain constant flip would not have worked: the reconcile effect in useChatProviderState wrote the *resolved default* back to localStorage['claude-model'] unconditionally, so every browser that loaded today's build now holds 'claude-fable-5-1' there and would have been treated as an explicit pick — the mirror image of the 'default'-literal trap the .4 fix was for. So: - Both defaults (backend CLAUDE_FALLBACK_MODELS.DEFAULT, frontend FALLBACK_DEFAULT_MODEL.claude) now point at 'claude-sonnet-5' by exact API id, and the Sonnet OPTIONS entry is renamed to match so effort resolution keeps working. Fable stays selectable. - normalizeStoredProviderModel treats 'default' and 'claude-fable-5-1' as persisted-fallback sentinels (never a deliberate pick — the per-session model-change cache had never been written before today), used by both the useState initializer and pickStoredOrCurrent, so the catalog reconcile can't re-pin a browser to a fallback it has moved away from. - The reconcile effect only persists explicit picks now; a fallback-only state clears the key instead of writing the default into it. That is what had frozen every browser on whatever the default was at first load. Co-Authored-By: Claude Sonnet 5 --- .../list/claude/claude-models.provider.ts | 26 ++++++----- server/routes/agent.js | 2 +- .../chat/hooks/useChatProviderState.ts | 24 +++++++--- src/stores/providerModelDefaults.ts | 45 +++++++++++++------ .../tests/providerModelDefaults.test.ts | 34 ++++++++------ 5 files changed, 84 insertions(+), 47 deletions(-) diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index 787535e869..665ae871eb 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -31,10 +31,9 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { }, }, { - // divizend: the box's own operator-standing default (see cloud-admin-box's - // CLAUDE.md) pins the exact API model id, not a generic 'fable' alias that - // could silently drift to a different snapshot later — this value is also - // what CLAUDE_FALLBACK_MODELS.DEFAULT below points at. + // divizend: exact API model id rather than a generic 'fable' alias that + // could silently drift to a different snapshot later. Selectable, but no + // longer the default (see DEFAULT below) — it burns tokens far faster. value: 'claude-fable-5-1', label: 'Fable', description: 'Fable 5.1 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', @@ -50,9 +49,11 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { }, }, { - value: "sonnet", - label: "Sonnet", - description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok", + // divizend: exact API model id (same principle as the Fable entry); this is + // what CLAUDE_FALLBACK_MODELS.DEFAULT below points at. + value: 'claude-sonnet-5', + label: 'Sonnet', + description: 'Sonnet 5 · Best for everyday tasks', effort: { default: 'high', values: [ @@ -113,11 +114,12 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok', }, ], - // divizend: this box's operator-standing default is Fable, not upstream's plain - // 'default' (~Sonnet) — see cloud-admin-box's CLAUDE.md model-pin note. The 'default' - // OPTION entry above is untouched, so explicitly picking "Default (recommended)" from - // the model picker still behaves as upstream intended. - DEFAULT: 'claude-fable-5-1', + // divizend: this box's operator-standing default is Sonnet 5 by its exact API id + // (was Fable for part of 2026-09-05 — reverted the same day: it ate tokens far too + // fast). Not upstream's 'default' literal, which resolves independently of any + // settings.json pin. The 'default' OPTION entry above is untouched, so explicitly + // picking "Default (recommended)" from the picker still behaves as upstream intended. + DEFAULT: 'claude-sonnet-5', }; export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => { diff --git a/server/routes/agent.js b/server/routes/agent.js index 6889896aa7..78c7b586c8 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -646,7 +646,7 @@ class ResponseCollector { * * @param {string} model - (Optional) Model identifier for providers. * - * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'claude-fable-5-1' + * Claude models: 'default', 'claude-sonnet-5', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'claude-fable-5-1' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index df00bb767c..17edcfaa4a 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -15,13 +15,14 @@ import { FALLBACK_PROVIDER_EFFORT_VALUES, toProviderEffortOptions, } from '../constants/providerEffort'; -import { resolveInitialProviderModel } from '../../../stores/providerModelDefaults'; +import { normalizeStoredProviderModel, resolveInitialProviderModel } from '../../../stores/providerModelDefaults'; const FALLBACK_DEFAULT_MODEL: Record = { - // divizend: this box's operator-standing default — the exact API model id - // pinned in cloud-admin-box's ~/.claude/settings.json, not a generic 'fable' - // alias that could silently drift to a different snapshot later. - claude: 'claude-fable-5-1', + // divizend: this box's operator-standing default by exact API model id (not + // an alias that could drift, and not upstream's 'default' literal, which the + // backend's own fallback never gets a chance to override). Must match + // CLAUDE_FALLBACK_MODELS.DEFAULT server-side. + claude: 'claude-sonnet-5', cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', @@ -288,7 +289,9 @@ export function useChatProviderState({ selectedSession, selectedProject: _select current: string, def: ProviderModelsDefinition, ): string => { - const stored = localStorage.getItem(storageKey); + // Only an explicit prior pick counts — a persisted fallback sentinel must + // not pin the user to a default this app has since moved away from. + const stored = normalizeStoredProviderModel(localStorage.getItem(storageKey)); if (stored && def.OPTIONS.some((o) => o.value === stored)) { return stored; } @@ -368,7 +371,14 @@ export function useChatProviderState({ selectedSession, selectedProject: _select if (next !== claudeModel) { setClaudeModel(next); } - if (localStorage.getItem('claude-model') !== next) { + // Persist only explicit picks (setStoredProviderModel/selectProviderModel + // write those directly). Writing the resolved *fallback* here is what + // used to freeze every browser on whatever the default happened to be + // at first load, so a fallback-only state clears the key instead. + const explicit = normalizeStoredProviderModel(localStorage.getItem('claude-model')); + if (explicit === null) { + localStorage.removeItem('claude-model'); + } else if (explicit !== next) { localStorage.setItem('claude-model', next); } } diff --git a/src/stores/providerModelDefaults.ts b/src/stores/providerModelDefaults.ts index e0debe5d84..74c2d91115 100644 --- a/src/stores/providerModelDefaults.ts +++ b/src/stores/providerModelDefaults.ts @@ -7,24 +7,41 @@ */ /** - * Resolves the initial model value for a provider from a (possibly absent) - * localStorage-cached value. + * Stored values that were only ever a *fallback* this app wrote into + * localStorage on its own, never a deliberate pick by the user — so they must + * not be honored as one. The reconcile effect in useChatProviderState used to + * persist the resolved default unconditionally, which is how these got there. * - * A stored value of literally 'default' is treated the same as "no real - * preference" rather than an explicit choice: it's what every browser that - * loaded this app before `fallback` pointed at a real model would have - * cached, purely as an artifact of the old fallback's own value, never a - * deliberate pick. Falling through to `fallback` here means a browser with - * that stale cache picks up a new fallback (e.g. this box's Fable pin) - * without any manual action, while any other stored value — an explicit - * pick of 'sonnet', 'opus', a real model id, etc. — is left untouched. + * - 'default': upstream's fallback literal (every browser that loaded the app + * before 2026-09-05 has it cached). + * - 'claude-fable-5-1': this box's fallback for part of 2026-09-05, before it + * was reverted for token cost. Nobody had explicitly picked Fable before + * that day (the per-session model-change cache had never been written), so + * a stored copy of it can only be the persisted fallback. + */ +const NON_PREFERENCE_STORED_MODELS: ReadonlySet = new Set(['default', 'claude-fable-5-1']); + +/** + * Normalizes a localStorage-cached model to an explicit preference, or null + * when there isn't one (absent, empty, or one of the persisted-fallback + * sentinels above). + */ +export function normalizeStoredProviderModel(stored: string | null | undefined): string | null { + if (!stored || NON_PREFERENCE_STORED_MODELS.has(stored)) { + return null; + } + return stored; +} + +/** + * Resolves the initial model value for a provider from a (possibly absent) + * localStorage-cached value: an explicit prior pick wins, anything else falls + * through to `fallback` — so a browser holding only a stale persisted + * fallback picks up a new one without any manual action. */ export function resolveInitialProviderModel( stored: string | null | undefined, fallback: string, ): string { - if (stored && stored !== 'default') { - return stored; - } - return fallback; + return normalizeStoredProviderModel(stored) ?? fallback; } diff --git a/src/stores/tests/providerModelDefaults.test.ts b/src/stores/tests/providerModelDefaults.test.ts index dfdc9ee0e3..009df2b0dc 100644 --- a/src/stores/tests/providerModelDefaults.test.ts +++ b/src/stores/tests/providerModelDefaults.test.ts @@ -1,23 +1,31 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { resolveInitialProviderModel } from '../providerModelDefaults'; +import { normalizeStoredProviderModel, resolveInitialProviderModel } from '../providerModelDefaults'; test('resolveInitialProviderModel falls through to the fallback when nothing is stored', () => { - assert.equal(resolveInitialProviderModel(null, 'claude-fable-5-1'), 'claude-fable-5-1'); - assert.equal(resolveInitialProviderModel(undefined, 'claude-fable-5-1'), 'claude-fable-5-1'); - assert.equal(resolveInitialProviderModel('', 'claude-fable-5-1'), 'claude-fable-5-1'); + assert.equal(resolveInitialProviderModel(null, 'claude-sonnet-5'), 'claude-sonnet-5'); + assert.equal(resolveInitialProviderModel(undefined, 'claude-sonnet-5'), 'claude-sonnet-5'); + assert.equal(resolveInitialProviderModel('', 'claude-sonnet-5'), 'claude-sonnet-5'); }); -test('resolveInitialProviderModel upgrades an already-cached literal "default" to the new fallback', () => { - // Reproduces the production bug: a browser that loaded this app before the - // fallback pointed at a real model cached the string 'default' itself, and - // a plain `stored || fallback` never picks up a later fallback change for - // that browser again. - assert.equal(resolveInitialProviderModel('default', 'claude-fable-5-1'), 'claude-fable-5-1'); +test('a cached literal "default" is a persisted fallback, not a pick, and gets the new fallback', () => { + // Reproduces the 2026-09-05 morning bug: browsers that loaded the app while + // the fallback was 'default' had that literal persisted, and a plain + // `stored || fallback` never picked up a later fallback change for them. + assert.equal(resolveInitialProviderModel('default', 'claude-sonnet-5'), 'claude-sonnet-5'); }); -test('resolveInitialProviderModel preserves an explicit prior model choice', () => { - assert.equal(resolveInitialProviderModel('sonnet', 'claude-fable-5-1'), 'sonnet'); - assert.equal(resolveInitialProviderModel('opus', 'claude-fable-5-1'), 'opus'); +test('a cached "claude-fable-5-1" is likewise a persisted fallback and gets the new fallback', () => { + // Reproduces the mirror-image trap from the same afternoon: the reconcile + // effect persisted the Fable fallback into every browser, so reverting the + // constant alone would have left them all stuck on Fable. + assert.equal(resolveInitialProviderModel('claude-fable-5-1', 'claude-sonnet-5'), 'claude-sonnet-5'); + assert.equal(normalizeStoredProviderModel('claude-fable-5-1'), null); +}); + +test('an explicit prior model choice is preserved', () => { + assert.equal(resolveInitialProviderModel('opus', 'claude-sonnet-5'), 'opus'); + assert.equal(resolveInitialProviderModel('haiku', 'claude-sonnet-5'), 'haiku'); + assert.equal(normalizeStoredProviderModel('opus'), 'opus'); }); From 7c2ab95945e0542d98cbf4933f8d56f78d9d4214 Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Mon, 7 Sep 2026 09:11:43 +0000 Subject: [PATCH 6/7] feat: derive the default Claude model from CLOUDCLI_DEFAULT_CLAUDE_MODEL Read once at process start (cloud-admin-box rollout-restarts on a switch). A valid but unlisted id gets a synthesized picker entry so effort resolution keeps matching; unset/invalid falls back to claude-sonnet-5 (invalid logs a startup warning). Co-Authored-By: Claude Sonnet 5 --- .../list/claude/claude-models.provider.ts | 32 +++++++---- .../list/claude/default-claude-model.ts | 52 ++++++++++++++++++ .../tests/default-claude-model.test.ts | 54 +++++++++++++++++++ 3 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 server/modules/providers/list/claude/default-claude-model.ts create mode 100644 server/modules/providers/tests/default-claude-model.test.ts diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index 665ae871eb..f668e5ffcd 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -13,9 +13,9 @@ import { buildDefaultProviderCurrentActiveModel, writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; +import { DEFAULT_CLAUDE_MODEL_ENV, resolveDefaultClaudeModel } from './default-claude-model.js'; -export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { - OPTIONS: [ +const BASE_CLAUDE_MODEL_OPTIONS: ProviderModelOption[] = [ { value: 'default', label: 'Default (recommended)', @@ -49,8 +49,7 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { }, }, { - // divizend: exact API model id (same principle as the Fable entry); this is - // what CLAUDE_FALLBACK_MODELS.DEFAULT below points at. + // divizend: exact API model id, not a generic alias that could drift. value: 'claude-sonnet-5', label: 'Sonnet', description: 'Sonnet 5 · Best for everyday tasks', @@ -113,13 +112,24 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { label: 'Haiku', description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok', }, - ], - // divizend: this box's operator-standing default is Sonnet 5 by its exact API id - // (was Fable for part of 2026-09-05 — reverted the same day: it ate tokens far too - // fast). Not upstream's 'default' literal, which resolves independently of any - // settings.json pin. The 'default' OPTION entry above is untouched, so explicitly - // picking "Default (recommended)" from the picker still behaves as upstream intended. - DEFAULT: 'claude-sonnet-5', +]; + +// divizend: the default is not a constant in this file any more — it comes from +// cloud-admin-box's `cloud-admin-box-claude-model` Secret via +// CLOUDCLI_DEFAULT_CLAUDE_MODEL (baked-in fallback: claude-sonnet-5). The 'default' +// OPTION entry above is untouched, so explicitly picking "Default (recommended)" +// still behaves as upstream intended. +const resolvedDefault = resolveDefaultClaudeModel( + process.env[DEFAULT_CLAUDE_MODEL_ENV], + BASE_CLAUDE_MODEL_OPTIONS, +); +if (resolvedDefault.warning) { + console.warn(`[Claude models] ${resolvedDefault.warning}`); +} + +export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: resolvedDefault.options, + DEFAULT: resolvedDefault.defaultModel, }; export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => { diff --git a/server/modules/providers/list/claude/default-claude-model.ts b/server/modules/providers/list/claude/default-claude-model.ts new file mode 100644 index 0000000000..49aa44418c --- /dev/null +++ b/server/modules/providers/list/claude/default-claude-model.ts @@ -0,0 +1,52 @@ +import type { ProviderModelOption } from '@/shared/types.js'; + +/** + * cloud-admin-box wires its Kubernetes Secret `cloud-admin-box-claude-model` + * into this env var. Read once at process start: a switch rollout-restarts + * the pod, so there is no live re-read to get wrong. + */ +export const DEFAULT_CLAUDE_MODEL_ENV = 'CLOUDCLI_DEFAULT_CLAUDE_MODEL'; +export const BAKED_IN_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5'; +/** Exact API model ids only (e.g. claude-sonnet-5, claude-fable-5-1, claude-sonnet-5[1m]). */ +export const CLAUDE_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*(\[1m\])?$/; + +export type ResolvedDefaultClaudeModel = { + defaultModel: string; + options: ProviderModelOption[]; + warning?: string; +}; + +export function resolveDefaultClaudeModel( + envValue: string | undefined, + baseOptions: ProviderModelOption[], +): ResolvedDefaultClaudeModel { + const value = envValue?.trim() ?? ''; + if (!value) { + return { defaultModel: BAKED_IN_DEFAULT_CLAUDE_MODEL, options: baseOptions }; + } + if (!CLAUDE_MODEL_ID_PATTERN.test(value)) { + return { + defaultModel: BAKED_IN_DEFAULT_CLAUDE_MODEL, + options: baseOptions, + warning: `${DEFAULT_CLAUDE_MODEL_ENV}="${value}" is not a valid Claude model id; using ${BAKED_IN_DEFAULT_CLAUDE_MODEL}`, + }; + } + if (baseOptions.some((option) => option.value === value)) { + return { defaultModel: value, options: baseOptions }; + } + return { + defaultModel: value, + options: [ + ...baseOptions, + { + value, + label: value, + description: `Configured via ${DEFAULT_CLAUDE_MODEL_ENV}`, + effort: { + default: 'high', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'max' }], + }, + }, + ], + }; +} diff --git a/server/modules/providers/tests/default-claude-model.test.ts b/server/modules/providers/tests/default-claude-model.test.ts new file mode 100644 index 0000000000..54d591e6bf --- /dev/null +++ b/server/modules/providers/tests/default-claude-model.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + BAKED_IN_DEFAULT_CLAUDE_MODEL, + DEFAULT_CLAUDE_MODEL_ENV, + resolveDefaultClaudeModel, +} from '../list/claude/default-claude-model.js'; +import type { ProviderModelOption } from '@/shared/types.js'; + +const BASE: ProviderModelOption[] = [ + { value: 'default', label: 'Default (recommended)' }, + { value: 'claude-sonnet-5', label: 'Sonnet', effort: { default: 'high', values: [{ value: 'high' }] } }, + { value: 'claude-fable-5-1', label: 'Fable', effort: { default: 'high', values: [{ value: 'xhigh' }] } }, +]; + +test('unset env → baked-in default, options untouched, no warning', () => { + const r = resolveDefaultClaudeModel(undefined, BASE); + assert.equal(r.defaultModel, BAKED_IN_DEFAULT_CLAUDE_MODEL); + assert.equal(r.defaultModel, 'claude-sonnet-5'); + assert.deepEqual(r.options, BASE); + assert.equal(r.warning, undefined); + assert.equal(resolveDefaultClaudeModel(' ', BASE).defaultModel, 'claude-sonnet-5'); +}); + +test('a known id becomes DEFAULT without adding an option', () => { + const r = resolveDefaultClaudeModel(' claude-fable-5-1 ', BASE); + assert.equal(r.defaultModel, 'claude-fable-5-1'); + assert.equal(r.options.length, BASE.length); + assert.equal(r.warning, undefined); +}); + +test('a valid but unknown id becomes DEFAULT and gets a synthesized picker entry with effort levels', () => { + const r = resolveDefaultClaudeModel('claude-haiku-4-5-20251001', BASE); + assert.equal(r.defaultModel, 'claude-haiku-4-5-20251001'); + const added = r.options.find((o) => o.value === 'claude-haiku-4-5-20251001'); + assert.ok(added); + assert.equal(added.label, 'claude-haiku-4-5-20251001'); + assert.match(added.description ?? '', new RegExp(DEFAULT_CLAUDE_MODEL_ENV)); + assert.deepEqual(added.effort?.values.map((v) => v.value), ['low', 'medium', 'high', 'max']); + assert.equal(r.warning, undefined); +}); + +test('an invalid id falls back to the baked-in default with a warning naming the env var', () => { + const r = resolveDefaultClaudeModel('Fable 5.1!', BASE); + assert.equal(r.defaultModel, 'claude-sonnet-5'); + assert.deepEqual(r.options, BASE); + assert.match(r.warning ?? '', new RegExp(DEFAULT_CLAUDE_MODEL_ENV)); + assert.match(r.warning ?? '', /Fable 5\.1!/); +}); + +test('the [1m] suffix is accepted', () => { + assert.equal(resolveDefaultClaudeModel('claude-sonnet-5[1m]', BASE).defaultModel, 'claude-sonnet-5[1m]'); +}); From 0565013acb2f8ce77e3d014b28d67bb7a31de65d Mon Sep 17 00:00:00 2001 From: Julian Nalenz Date: Mon, 7 Sep 2026 09:16:29 +0000 Subject: [PATCH 7/7] feat: frontend follows the server's default Claude model instead of inventing one Sends `model` only for an explicit pick; displays the catalog DEFAULT otherwise; refetches the catalog on websocket reconnect so open tabs follow a switch; picking the catalog default clears the explicit pick. Also fixes an import-order lint warning introduced by the previous commit's new import in claude-models.provider.ts. Co-Authored-By: Claude Sonnet 5 --- .../list/claude/claude-models.provider.ts | 1 + .../chat/hooks/useChatComposerState.ts | 6 +- .../chat/hooks/useChatProviderState.ts | 65 ++++++++++--------- src/components/chat/view/ChatInterface.tsx | 7 +- src/stores/providerModelDefaults.ts | 27 +++++--- .../tests/providerModelDefaults.test.ts | 45 ++++++------- 6 files changed, 88 insertions(+), 63 deletions(-) diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index f668e5ffcd..e745e07c59 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -13,6 +13,7 @@ import { buildDefaultProviderCurrentActiveModel, writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; + import { DEFAULT_CLAUDE_MODEL_ENV, resolveDefaultClaudeModel } from './default-claude-model.js'; const BASE_CLAUDE_MODEL_OPTIONS: ProviderModelOption[] = [ diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 7543ebae77..f4413ab4ae 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -43,6 +43,7 @@ interface UseChatComposerStateArgs { resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode; cursorModel: string; claudeModel: string; + claudeExplicitModel: string | null; codexModel: string; currentProviderEffort: string; opencodeModel: string; @@ -195,6 +196,7 @@ export function useChatComposerState({ resolvePermissionModeForProvider, cursorModel, claudeModel, + claudeExplicitModel, codexModel, currentProviderEffort, opencodeModel, @@ -621,7 +623,7 @@ export function useChatComposerState({ ? codexModel : provider === 'opencode' ? opencodeModel - : claudeModel; + : claudeExplicitModel ?? undefined; // Claude: server DEFAULT unless explicitly picked return { model, @@ -632,7 +634,7 @@ export function useChatComposerState({ sessionSummary: getNotificationSessionSummary(selectedSession, currentInput), }; }, [ - claudeModel, + claudeExplicitModel, codexModel, currentProviderEffort, cursorModel, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 17edcfaa4a..8cc23c3f1c 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -15,14 +15,13 @@ import { FALLBACK_PROVIDER_EFFORT_VALUES, toProviderEffortOptions, } from '../constants/providerEffort'; -import { normalizeStoredProviderModel, resolveInitialProviderModel } from '../../../stores/providerModelDefaults'; - -const FALLBACK_DEFAULT_MODEL: Record = { - // divizend: this box's operator-standing default by exact API model id (not - // an alias that could drift, and not upstream's 'default' literal, which the - // backend's own fallback never gets a chance to override). Must match - // CLAUDE_FALLBACK_MODELS.DEFAULT server-side. - claude: 'claude-sonnet-5', +import { + nextExplicitClaudeModel, + normalizeStoredProviderModel, + resolveDisplayedClaudeModel, +} from '../../../stores/providerModelDefaults'; + +const FALLBACK_DEFAULT_MODEL: Record, string> = { cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', @@ -99,8 +98,10 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor; }); - const [claudeModel, setClaudeModel] = useState(() => { - return resolveInitialProviderModel(localStorage.getItem('claude-model'), FALLBACK_DEFAULT_MODEL.claude); + // Explicit user pick only (null = follow the server's default). Legacy + // persisted-fallback sentinels are filtered out on read. + const [claudeExplicitModel, setClaudeExplicitModelState] = useState(() => { + return normalizeStoredProviderModel(localStorage.getItem('claude-model')); }); const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; @@ -137,10 +138,21 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const providerModelsRequestIdRef = useRef(0); + const claudeCatalogDefault = providerModelCatalog.claude?.DEFAULT; + const claudeModel = useMemo( + () => resolveDisplayedClaudeModel(claudeExplicitModel, claudeCatalogDefault), + [claudeExplicitModel, claudeCatalogDefault], + ); + const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => { if (targetProvider === 'claude') { - setClaudeModel(model); - localStorage.setItem('claude-model', model); + const next = nextExplicitClaudeModel(model, claudeCatalogDefault); + setClaudeExplicitModelState(next); + if (next === null) { + localStorage.removeItem('claude-model'); + } else { + localStorage.setItem('claude-model', next); + } return; } @@ -158,7 +170,7 @@ export function useChatProviderState({ selectedSession, selectedProject: _select setOpenCodeModel(model); localStorage.setItem('opencode-model', model); - }, []); + }, [claudeCatalogDefault]); const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => { setProviderEfforts((previous) => ( @@ -365,24 +377,16 @@ export function useChatProviderState({ selectedSession, selectedProject: _select }), [claudeModel, cursorModel, codexModel, opencodeModel]); useEffect(() => { + // Only validate an explicit pick against the catalog; never write a + // fallback into localStorage (that is what used to freeze browsers on a + // stale default — see providerModelDefaults.ts). const claude = providerModelCatalog.claude; - if (claude) { - const next = pickStoredOrCurrent('claude-model', claudeModel, claude); - if (next !== claudeModel) { - setClaudeModel(next); - } - // Persist only explicit picks (setStoredProviderModel/selectProviderModel - // write those directly). Writing the resolved *fallback* here is what - // used to freeze every browser on whatever the default happened to be - // at first load, so a fallback-only state clears the key instead. - const explicit = normalizeStoredProviderModel(localStorage.getItem('claude-model')); - if (explicit === null) { - localStorage.removeItem('claude-model'); - } else if (explicit !== next) { - localStorage.setItem('claude-model', next); - } + if (!claude || claudeExplicitModel === null) return; + if (!claude.OPTIONS.some((option) => option.value === claudeExplicitModel)) { + setClaudeExplicitModelState(null); + localStorage.removeItem('claude-model'); } - }, [providerModelCatalog.claude, claudeModel]); + }, [providerModelCatalog.claude, claudeExplicitModel]); useEffect(() => { const cursor = providerModelCatalog.cursor; @@ -578,7 +582,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select cursorModel, setCursorModel, claudeModel, - setClaudeModel, + claudeExplicitModel, + setClaudeModel: (model: string) => setStoredProviderModel('claude', model), codexModel, setCodexModel, currentProviderEffort, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2d7a8dcbb7..a5f0efeaf5 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -66,6 +66,7 @@ function ChatInterface({ cursorModel, setCursorModel, claudeModel, + claudeExplicitModel, setClaudeModel, codexModel, setCodexModel, @@ -200,6 +201,7 @@ function ChatInterface({ cyclePermissionMode, cursorModel, claudeModel, + claudeExplicitModel, codexModel, currentProviderEffort, opencodeModel, @@ -225,6 +227,9 @@ function ChatInterface({ // `chat_subscribed` ack restores or clears the activity indicator, replays // missed live events, and re-attaches a still-running stream to this socket. const handleWebSocketReconnect = useCallback(async () => { + // A default-model switch rollout-restarts the box; the reconnect is the + // moment to pick up the new catalog DEFAULT (bypass the client cache). + void hardRefreshProviderModels(); if (!selectedProject || !selectedSession) return; await sessionStore.refreshFromServer(selectedSession.id); statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); @@ -235,7 +240,7 @@ function ChatInterface({ lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, }], }); - }, [selectedProject, selectedSession, sendMessage, sessionStore]); + }, [hardRefreshProviderModels, selectedProject, selectedSession, sendMessage, sessionStore]); useChatRealtimeHandlers({ subscribe, diff --git a/src/stores/providerModelDefaults.ts b/src/stores/providerModelDefaults.ts index 74c2d91115..96d16c333e 100644 --- a/src/stores/providerModelDefaults.ts +++ b/src/stores/providerModelDefaults.ts @@ -34,14 +34,25 @@ export function normalizeStoredProviderModel(stored: string | null | undefined): } /** - * Resolves the initial model value for a provider from a (possibly absent) - * localStorage-cached value: an explicit prior pick wins, anything else falls - * through to `fallback` — so a browser holding only a stale persisted - * fallback picks up a new one without any manual action. + * What the composer shows/uses: an explicit pick, else the server catalog's + * DEFAULT, else '' while the catalog is still loading. The frontend never has a + * default of its own — see cloud-admin-box's + * docs/superpowers/specs/2026-09-05-runtime-default-claude-model-design.md §3. */ -export function resolveInitialProviderModel( - stored: string | null | undefined, - fallback: string, +export function resolveDisplayedClaudeModel( + explicit: string | null, + catalogDefault: string | null | undefined, ): string { - return normalizeStoredProviderModel(stored) ?? fallback; + return explicit ?? catalogDefault ?? ''; +} + +/** + * Picking the entry that *is* the catalog default means "follow the default", so + * the explicit pick is cleared rather than pinned to today's value. + */ +export function nextExplicitClaudeModel( + picked: string, + catalogDefault: string | null | undefined, +): string | null { + return picked === catalogDefault ? null : picked; } diff --git a/src/stores/tests/providerModelDefaults.test.ts b/src/stores/tests/providerModelDefaults.test.ts index 009df2b0dc..0f8f129bf8 100644 --- a/src/stores/tests/providerModelDefaults.test.ts +++ b/src/stores/tests/providerModelDefaults.test.ts @@ -1,31 +1,32 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { normalizeStoredProviderModel, resolveInitialProviderModel } from '../providerModelDefaults'; +import { + nextExplicitClaudeModel, + normalizeStoredProviderModel, + resolveDisplayedClaudeModel, +} from '../providerModelDefaults'; -test('resolveInitialProviderModel falls through to the fallback when nothing is stored', () => { - assert.equal(resolveInitialProviderModel(null, 'claude-sonnet-5'), 'claude-sonnet-5'); - assert.equal(resolveInitialProviderModel(undefined, 'claude-sonnet-5'), 'claude-sonnet-5'); - assert.equal(resolveInitialProviderModel('', 'claude-sonnet-5'), 'claude-sonnet-5'); -}); - -test('a cached literal "default" is a persisted fallback, not a pick, and gets the new fallback', () => { - // Reproduces the 2026-09-05 morning bug: browsers that loaded the app while - // the fallback was 'default' had that literal persisted, and a plain - // `stored || fallback` never picked up a later fallback change for them. - assert.equal(resolveInitialProviderModel('default', 'claude-sonnet-5'), 'claude-sonnet-5'); +test('normalizeStoredProviderModel: absent/empty and persisted-fallback sentinels are "no preference"', () => { + assert.equal(normalizeStoredProviderModel(null), null); + assert.equal(normalizeStoredProviderModel(''), null); + // 'default' (upstream fallback literal) and 'claude-fable-5-1' (this box's fallback for + // part of 2026-09-05) were written into localStorage by the app itself, never picked. + assert.equal(normalizeStoredProviderModel('default'), null); + assert.equal(normalizeStoredProviderModel('claude-fable-5-1'), null); + assert.equal(normalizeStoredProviderModel('opus'), 'opus'); }); -test('a cached "claude-fable-5-1" is likewise a persisted fallback and gets the new fallback', () => { - // Reproduces the mirror-image trap from the same afternoon: the reconcile - // effect persisted the Fable fallback into every browser, so reverting the - // constant alone would have left them all stuck on Fable. - assert.equal(resolveInitialProviderModel('claude-fable-5-1', 'claude-sonnet-5'), 'claude-sonnet-5'); - assert.equal(normalizeStoredProviderModel('claude-fable-5-1'), null); +test('resolveDisplayedClaudeModel: explicit pick wins, else catalog default, else empty', () => { + assert.equal(resolveDisplayedClaudeModel('opus', 'claude-sonnet-5'), 'opus'); + assert.equal(resolveDisplayedClaudeModel(null, 'claude-sonnet-5'), 'claude-sonnet-5'); + assert.equal(resolveDisplayedClaudeModel(null, undefined), ''); }); -test('an explicit prior model choice is preserved', () => { - assert.equal(resolveInitialProviderModel('opus', 'claude-sonnet-5'), 'opus'); - assert.equal(resolveInitialProviderModel('haiku', 'claude-sonnet-5'), 'haiku'); - assert.equal(normalizeStoredProviderModel('opus'), 'opus'); +test('nextExplicitClaudeModel: picking the catalog default clears the explicit pick', () => { + // A browser that picks "the default" should follow future switches, not freeze on + // whatever the default happened to be today. + assert.equal(nextExplicitClaudeModel('claude-sonnet-5', 'claude-sonnet-5'), null); + assert.equal(nextExplicitClaudeModel('opus', 'claude-sonnet-5'), 'opus'); + assert.equal(nextExplicitClaudeModel('opus', undefined), 'opus'); });