From ce7b3bc083bdded7aca318c93263f4a54ec23824 Mon Sep 17 00:00:00 2001 From: gnuthall Date: Fri, 17 Jul 2026 14:08:23 -0600 Subject: [PATCH 1/2] fix(models): skip Claude's placeholder when reading transcript models After an API error (e.g. 529 Overloaded), Claude Code appends assistant rows stamped model "" (the error notice, "No response requested.", session-limit messages). The transcript scanner adopted that placeholder as the session's model, and since transcript-sourced models feed the send path, the next turn was sent with model "" and failed with "There's an issue with the selected model ()" until the user switched surfaces. extractClaudeEventModel now rejects angle-bracketed placeholder values for both event.model and message.model, so the backward scan continues to the last genuine turn and recovers the session's real model. Co-Authored-By: Claude Fable 5 --- .../list/claude/claude-models.provider.ts | 15 +- .../providers/tests/claude-models.test.ts | 139 ++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 server/modules/providers/tests/claude-models.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 2c80cc4034..af1c369653 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -139,6 +139,13 @@ const ANSI_PATTERN = new RegExp( 'g', ); +// Claude Code stamps locally-fabricated assistant rows (API-error notices, +// "No response requested.", session-limit messages) with the placeholder +// model "". Adopting it would feed a non-model into the send path, +// so placeholder values are skipped and the transcript scan keeps walking back +// to the last real turn. +const isPlaceholderClaudeModel = (value: string): boolean => /^<.*>$/.test(value); + const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => { const eventSessionId = event.sessionId ?? event.session_id; if (eventSessionId && eventSessionId !== sessionId) { @@ -151,12 +158,16 @@ const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): str } const directModel = event.model?.trim(); - if (directModel) { + if (directModel && !isPlaceholderClaudeModel(directModel)) { return directModel; } const messageModel = event.message?.model?.trim(); - return messageModel || null; + if (messageModel && !isPlaceholderClaudeModel(messageModel)) { + return messageModel; + } + + return null; }; const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, ''); diff --git a/server/modules/providers/tests/claude-models.test.ts b/server/modules/providers/tests/claude-models.test.ts new file mode 100644 index 0000000000..0ee91c8add --- /dev/null +++ b/server/modules/providers/tests/claude-models.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { + CLAUDE_FALLBACK_MODELS, + ClaudeProviderModels, +} from '@/modules/providers/list/claude/claude-models.provider.js'; + +const PROVIDER_SESSION_ID = '77af7791-311d-4f0e-abbf-381f25ed775a'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-models-db-')); + 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 }); + } +} + +const writeSessionJsonl = async (dir: string, rows: unknown[]): Promise => { + const jsonlPath = path.join(dir, `${PROVIDER_SESSION_ID}.jsonl`); + await writeFile(jsonlPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8'); + return jsonlPath; +}; + +const registerSession = (jsonlPath: string, projectPath: string): string => + sessionsDb.createSession( + PROVIDER_SESSION_ID, + 'claude', + projectPath, + undefined, + undefined, + undefined, + jsonlPath, + ); + +test('claude current active model reads the last assistant turn from the transcript', async () => { + await withIsolatedDatabase(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-models-test-')); + try { + const jsonlPath = await writeSessionJsonl(dir, [ + { type: 'user', sessionId: PROVIDER_SESSION_ID, message: { content: 'hello' } }, + { + type: 'assistant', + sessionId: PROVIDER_SESSION_ID, + message: { model: 'claude-sonnet-4-5', content: [] }, + }, + ]); + const sessionId = registerSession(jsonlPath, dir); + + const active = await new ClaudeProviderModels().getCurrentActiveModel(sessionId); + assert.equal(active.model, 'claude-sonnet-4-5'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +test('claude current active model skips synthetic error rows and recovers the real model', async () => { + await withIsolatedDatabase(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-models-test-')); + try { + // After a 529 the CLI appends assistant rows with model "" + // (the API-error notice and "No response requested."); the session's real + // model lives in the last genuine turn before them. + const jsonlPath = await writeSessionJsonl(dir, [ + { type: 'user', sessionId: PROVIDER_SESSION_ID, message: { content: 'hello' } }, + { + type: 'assistant', + sessionId: PROVIDER_SESSION_ID, + message: { model: 'claude-sonnet-4-5', content: [] }, + }, + { + type: 'assistant', + sessionId: PROVIDER_SESSION_ID, + message: { + model: '', + content: [{ type: 'text', text: 'API Error: 529 Overloaded.' }], + }, + }, + { + type: 'assistant', + sessionId: PROVIDER_SESSION_ID, + message: { + model: '', + content: [{ type: 'text', text: 'No response requested.' }], + }, + }, + ]); + const sessionId = registerSession(jsonlPath, dir); + + const active = await new ClaudeProviderModels().getCurrentActiveModel(sessionId); + assert.equal(active.model, 'claude-sonnet-4-5'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +test('claude current active model falls back to the catalog default when every row is synthetic', async () => { + await withIsolatedDatabase(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-models-test-')); + try { + const jsonlPath = await writeSessionJsonl(dir, [ + { + type: 'assistant', + sessionId: PROVIDER_SESSION_ID, + message: { + model: '', + content: [{ type: 'text', text: 'API Error: 529 Overloaded.' }], + }, + }, + ]); + const sessionId = registerSession(jsonlPath, dir); + + const active = await new ClaudeProviderModels().getCurrentActiveModel(sessionId); + assert.equal(active.model, CLAUDE_FALLBACK_MODELS.DEFAULT); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); From 351f987eb8b0141749da2718ceb4cf95145c50ed Mon Sep 17 00:00:00 2001 From: gnuthall Date: Wed, 22 Jul 2026 20:03:05 -0600 Subject: [PATCH 2/2] test(models): cover the top-level event.model placeholder path CodeRabbit review on #1056: the guard checks both event.model and message.model, but the fixtures only set message.model. Stamp the 529 row with a top-level placeholder too so both paths are exercised. Co-Authored-By: Claude Fable 5 --- server/modules/providers/tests/claude-models.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/modules/providers/tests/claude-models.test.ts b/server/modules/providers/tests/claude-models.test.ts index 0ee91c8add..d80ef96656 100644 --- a/server/modules/providers/tests/claude-models.test.ts +++ b/server/modules/providers/tests/claude-models.test.ts @@ -90,6 +90,8 @@ test('claude current active model skips synthetic error rows and recovers the re { type: 'assistant', sessionId: PROVIDER_SESSION_ID, + // Placeholder in the top-level event model field. + model: '', message: { model: '', content: [{ type: 'text', text: 'API Error: 529 Overloaded.' }], @@ -98,6 +100,7 @@ test('claude current active model skips synthetic error rows and recovers the re { type: 'assistant', sessionId: PROVIDER_SESSION_ID, + // Placeholder only in message.model. message: { model: '', content: [{ type: 'text', text: 'No response requested.' }],