diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index 2c80cc4034..e745e07c59 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -14,8 +14,9 @@ import { writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; -export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { - OPTIONS: [ +import { DEFAULT_CLAUDE_MODEL_ENV, resolveDefaultClaudeModel } from './default-claude-model.js'; + +const BASE_CLAUDE_MODEL_OPTIONS: ProviderModelOption[] = [ { value: 'default', label: 'Default (recommended)', @@ -31,9 +32,12 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { }, }, { - value: 'fable', + // 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 · 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: [ @@ -46,9 +50,10 @@ 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, not a generic alias that could drift. + value: 'claude-sonnet-5', + label: 'Sonnet', + description: 'Sonnet 5 · Best for everyday tasks', effort: { default: 'high', values: [ @@ -108,8 +113,24 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { label: 'Haiku', description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok', }, - ], - DEFAULT: 'default', +]; + +// 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/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/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/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/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]'); +}); diff --git a/server/routes/agent.js b/server/routes/agent.js index 1ae605b69e..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]', 'fable' + * 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/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 1d0aac5af5..8cc23c3f1c 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 { + nextExplicitClaudeModel, + normalizeStoredProviderModel, + resolveDisplayedClaudeModel, +} from '../../../stores/providerModelDefaults'; -const FALLBACK_DEFAULT_MODEL: Record = { - claude: 'default', +const FALLBACK_DEFAULT_MODEL: Record, string> = { cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', opencode: 'anthropic/claude-sonnet-4-5', @@ -94,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 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; @@ -132,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; } @@ -153,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) => ( @@ -284,7 +301,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; } @@ -358,17 +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); - } - if (localStorage.getItem('claude-model') !== 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; @@ -564,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 new file mode 100644 index 0000000000..96d16c333e --- /dev/null +++ b/src/stores/providerModelDefaults.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ + +/** + * 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. + * + * - '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; +} + +/** + * 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 resolveDisplayedClaudeModel( + explicit: string | null, + catalogDefault: string | null | undefined, +): string { + 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/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/providerModelDefaults.test.ts b/src/stores/tests/providerModelDefaults.test.ts new file mode 100644 index 0000000000..0f8f129bf8 --- /dev/null +++ b/src/stores/tests/providerModelDefaults.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + nextExplicitClaudeModel, + normalizeStoredProviderModel, + resolveDisplayedClaudeModel, +} from '../providerModelDefaults'; + +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('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('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'); +}); 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 6117cbb7b3..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 @@ -425,6 +413,18 @@ 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; + // ─── Hook ──────────────────────────────────────────────────────────────────── export function useSessionStore() { @@ -613,19 +613,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 +682,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);