a session remembers which model it is using

resume a conversation and the model picker showed the first entry in the catalogue instead of the model the
session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious
the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it
introduced.

two defects, stacked.

the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and
both are volatile — a panel channel and react-router location state. on a hard load neither exists, the
settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired
`onModelChange(providerModels[0].id)`.

and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI
aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way:
opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real
59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5,
claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix.

so the transcript cannot answer "what should the picker show", and a new table does:

  chat_session_models (session_id pk, model, updated_at)

officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than
officer's session id, because that is what the session list joins on — which is why the write waits for the
harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused
switch stores what answered.

not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that
table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan
far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a
row holding title and cwd would be a second source of truth for two fields that already have one.

BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post
hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently
never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already
applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine.

it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only
became possible today, so no older transcript can hold two, and first costs a bounded head read instead of
reading 59MB.

also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a
picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation
has started. an unset picker is the honest state when the server cannot say.

tests: the alias map against the five real transcript values, and the selector's default gated on
hasStarted. re-broke the guard to confirm it bites.

still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the
batched read (getChatSessionModels) are in place for it.
This commit is contained in:
2026-08-17 17:03:19 +00:00
parent 57bbcca9ae
commit 813bc200bf
16 changed files with 489 additions and 5 deletions
+21 -1
View File
@@ -4,7 +4,7 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb';
import { getUserSettings, getChatSessionModel } from 'officerdb';
import {
listClaudePwds,
listClaudeSessions,
@@ -29,6 +29,7 @@ import { listChatModels } from './list-models';
import { logger } from './logger';
import { resolveHomeDir } from '@@/user-home';
import type { ChatIdentity } from './claude-sessions';
import { officerModelForApiModel } from './model-aliases';
import { readSttConfig } from '../server-settings/stt';
/**
@@ -150,8 +151,27 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// own, so it gets neither rather than a fabricated answer.
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
/*
The model the PICKER should show, which is not the model `detail` reports.
`detail.model` is scraped out of the transcript, so it is an API id (`claude-opus-5`) — a different id
space from what the picker offers (`claude-code/opus[1m]`), and lossy: `opus` and `opus[1m]` both write
`claude-opus-5`, and `opusplan` writes whichever model answered. Handing that to the client resolved to
nothing in the catalogue, the picker fell back to the first entry, and a resumed conversation came back
showing the wrong model.
So the stored row wins where there is one, and the family guess is the fallback for sessions that have
not taken a turn since `chat_session_models` landed. Null when neither can answer — the client shows no
model rather than guessing, which is the whole defect being fixed.
*/
const officerModel =
(await getChatSessionModel(id).catch(() => null)) ?? officerModelForApiModel(detail.model) ?? null;
return ctx.json({
...detail,
// `detail.model` stays on the payload untouched — it is the honest record of what the API served, and
// the detail view may want it. `officerModel` is the separate question of what to select in the picker.
officerModel,
messages,
total,
offset: windowed ? start : 0,
+44
View File
@@ -12,6 +12,7 @@ import {
type AsUser,
} from '../../read-as-user';
import { runAsArgv } from '../../os-user';
import { isRealModel } from './model-aliases';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
@@ -858,6 +859,49 @@ function firstCwd(osUser: AsUser, filePath: string): string {
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
/**
* Every transcript this identity owns, with the first real model it recorded.
*
* For the `chat_session_models` backfill (`scripts/backfill-session-models.ts`) — the one caller that wants
* a model per session across the WHOLE tree rather than for one directory.
*
* ── Why the FIRST model is the right one to read ──
*
* Sounds wrong: the table stores the model a session is *currently* using, so the last should win. But
* changing model mid-conversation only became possible on 2026-08-17, so no transcript written before that
* can contain more than one. First and last are the same value for every session this backfill will ever
* see, and reading the first costs a bounded head read instead of a full one — which matters, because the
* transcripts on this machine run to 59MB.
*
* Any session that takes a real turn afterwards overwrites the guess with the exact alias, `[1m]` and all.
* A future re-run of the backfill cannot regress that: `seedMissingChatSessionModels` only inserts.
*
* `<synthetic>` is skipped — it is Claude Code's marker for its own generated entries, not a model.
*/
export function listTranscriptModels(who: ChatIdentity): { sessionId: string; apiModel: string }[] {
const projectsDir = claudeProjectsDir(who.home);
const out: { sessionId: string; apiModel: string }[] = [];
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
const path = join(projectsDir, file.slug, `${file.id}.jsonl`);
let head: string;
try {
// 64KB rather than the 32KB `firstCwd` uses: `cwd` is on the very first entry, while the first
// ASSISTANT entry (the only kind that carries a model) sits after the user's opening message, which
// can be long. A miss here costs one absent row, not a wrong one.
head = readHeadAs(who.osUser, path, 65536);
} catch {
continue; // Unreadable transcript. One missing row, and the next turn fills it in.
}
// Scan for the first `"model":"…"` that is not the synthetic marker. Regex rather than JSON.parse per
// line because the head read almost certainly ends mid-line.
const apiModel = [...head.matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]!).find(isRealModel);
if (apiModel) out.push({ sessionId: file.id, apiModel });
}
return out;
}
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const projectsDir = claudeProjectsDir(who.home);
const defaultCwd = who.home;
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test';
import { isRealModel, officerModelForApiModel, SYNTHETIC_MODEL } from './model-aliases';
/**
* The five model strings below are not invented — they are every distinct `message.model` found in a real
* 59MB transcript on this machine, in order of first appearance. Two of them are the reason this map is
* needed at all: `<synthetic>` is not a model, and `claude-haiku-4-5-20251001` carries a date suffix the
* others do not, so an exhaustive id list would have missed it.
*/
describe('officerModelForApiModel', () => {
test('resolves the families Officer offers', () => {
expect(officerModelForApiModel('claude-opus-5')).toBe('claude-code/opus');
expect(officerModelForApiModel('claude-sonnet-5')).toBe('claude-code/sonnet');
expect(officerModelForApiModel('claude-fable-5')).toBe('claude-code/fable');
// Date-suffixed, which is why the match is a family prefix rather than a lookup table.
expect(officerModelForApiModel('claude-haiku-4-5-20251001')).toBe('claude-code/haiku');
});
test('a model released tomorrow still resolves to its family', () => {
// The point of prefix matching: a new version must not silently become "unknown".
expect(officerModelForApiModel('claude-opus-6')).toBe('claude-code/opus');
expect(officerModelForApiModel('claude-sonnet-9-20301231')).toBe('claude-code/sonnet');
});
test('the synthetic marker is not a model', () => {
// Claude Code writes this for entries it generated itself. Storing it would put a value in the picker
// that the CLI would reject.
expect(officerModelForApiModel(SYNTHETIC_MODEL)).toBeNull();
expect(isRealModel(SYNTHETIC_MODEL)).toBe(false);
expect(isRealModel('claude-opus-5')).toBe(true);
});
test('mythos resolves to null rather than to a neighbouring family', () => {
// Project Glasswing only, so it has no Officer entry. A picker option that 401s is worse than none.
expect(officerModelForApiModel('claude-mythos-5')).toBeNull();
expect(officerModelForApiModel('claude-mythos-preview')).toBeNull();
});
test('null for anything it cannot honestly name', () => {
for (const input of [null, undefined, '', ' ', 'gpt-5', 'anthropic/claude-3-5', 'opus']) {
expect(officerModelForApiModel(input)).toBeNull();
}
});
test('the lossy direction is documented by the map itself', () => {
// `opus` and `opus[1m]` both serve claude-opus-5, so this can only ever return the bare family — which
// is exactly why a real turn must overwrite the guess. If someone "improves" this to return a [1m]
// variant, they have invented information the transcript does not contain.
expect(officerModelForApiModel('claude-opus-5')).not.toContain('[1m]');
expect(officerModelForApiModel('claude-sonnet-5')).not.toContain('[1m]');
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Anthropic API model id → the Officer id that most nearly names it.
*
* Two id spaces meet here. A transcript records what the API served (`claude-opus-5`); Officer's picker
* offers what the CLI accepts (`claude-code/opus`, `claude-code/opus[1m]`). This translates one to the
* other, and it is **deliberately lossy in one direction only** — that is the whole reason
* `chat_session_models` exists:
*
* - `opus` and `opus[1m]` both serve `claude-opus-5`, so the 1M variant cannot be recovered.
* - `opusplan` serves whichever of opus/sonnet answered that turn, so it cannot be recognised at all.
* - `mythos` has no Officer entry (Project Glasswing only), so it resolves to null rather than to a
* neighbouring family — offering a model this machine may not reach is worse than offering none.
*
* So this is the FALLBACK for a session that has not taken a turn since the table landed, and the seed for
* the backfill. A real turn overwrites its guess with the exact alias the user picked. Never use it to
* decide what to run — only to decide what to display.
*
* Matching is by FAMILY PREFIX rather than an exhaustive id list, because the API ids are not uniform:
* `claude-haiku-4-5-20251001` carries a date suffix while `claude-fable-5` does not, and new versions ship
* without warning. A prefix match keeps a model released tomorrow resolving to the right family instead of
* to null.
*/
/** Claude Code's own marker for an entry it generated itself. Not a model, and must never be stored. */
export const SYNTHETIC_MODEL = '<synthetic>';
const FAMILIES: { prefix: string; officerId: string }[] = [
{ prefix: 'claude-fable', officerId: 'claude-code/fable' },
{ prefix: 'claude-opus', officerId: 'claude-code/opus' },
{ prefix: 'claude-sonnet', officerId: 'claude-code/sonnet' },
{ prefix: 'claude-haiku', officerId: 'claude-code/haiku' },
];
/**
* The Officer model id for an API model id, or null when there is no honest answer.
*
* Null for `<synthetic>`, for mythos, for an OpenCode model, and for anything unrecognised. Callers treat
* null as "we do not know", which renders as no model rather than as a guess.
*/
export function officerModelForApiModel(apiModel: string | null | undefined): string | null {
if (!apiModel || apiModel === SYNTHETIC_MODEL) return null;
const id = apiModel.trim().toLowerCase();
if (!id) return null;
const family = FAMILIES.find((f) => id.startsWith(f.prefix));
return family?.officerId ?? null;
}
/** Whether a transcript entry's model is a real model rather than Claude Code's synthetic marker. */
export const isRealModel = (apiModel: string | null | undefined): boolean => !!apiModel && apiModel !== SYNTHETIC_MODEL;
+50 -1
View File
@@ -17,7 +17,14 @@ import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
import {
getUserSettings,
getEmailAccounts,
getChatEventsSince,
appendChatEvent,
getUserById,
setChatSessionModel,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
@@ -324,6 +331,35 @@ function createMessageHandler(sessionId: string, model: string) {
*/
let effective = model;
/*
Claude's transcript uuid, which is NOT `sessionId`.
`sessionId` is officer's handle; the session LIST is keyed by the transcript's own uuid, and the two are
different strings (see the `session:claude` docs in types.ts). So the durable model record has to be
keyed by the transcript id or it will never join to a list row — and the transcript id is not known when
the turn starts, only when the harness announces it. That is what this waits for.
*/
let transcriptId: string | null = null;
/**
* Write the model down, once both halves are known.
*
* Fire-and-forget with a swallowed failure: a chat turn must not die because a bookkeeping row would not
* write. The cost of losing it is that the picker falls back to a family guess on the next resume, which
* is the behaviour every session already had before this table existed.
*/
const record = (): void => {
if (!transcriptId) return;
void setChatSessionModel(transcriptId, effective).catch((err: unknown) => {
logger.warn('Could not record the session model', {
sessionId,
transcriptId,
model: effective,
error: err instanceof Error ? err.message : String(err),
});
});
};
return (msg: TurnMessage, seq?: number): void => {
const session = sessionManager.getSession(sessionId);
if (!session) return;
@@ -335,6 +371,19 @@ function createMessageHandler(sessionId: string, model: string) {
// and the orphan adopter all route on `session.model`, and a stale one sends a stop to the wrong
// sidecar's kill path.
sessionManager.setModel(sessionId, effective);
record();
}
// The transcript id arrives at the START of a turn, and again on the closing `result` for a session
// whose id was assigned mid-turn. Recording on both means a brand-new conversation gets its model
// written on its very first turn rather than on its second.
if (msg.type === 'session:claude' && msg.claudeSessionId) {
transcriptId = msg.claudeSessionId;
record();
}
if (msg.type === 'result' && msg.claudeSessionId && msg.claudeSessionId !== transcriptId) {
transcriptId = msg.claudeSessionId;
record();
}
foldIntoSession(session, msg, effective);