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
+74
View File
@@ -0,0 +1,74 @@
/**
* Seed `chat_session_models` for conversations that existed before the table did.
*
* Runs from `postdb:push`, so every machine gets it on the next schema push rather than needing a manual
* step per server. Bun fires `pre`/`post` hooks for colon-named scripts — verified on 1.3.14 against
* `db:push`, because a `post` hook that silently never runs is the worst outcome for a migration.
*
* ── Why a family guess is good enough here ──
*
* A transcript records the API model (`claude-opus-5`), and Officer's picker offers CLI aliases
* (`claude-code/opus`, `claude-code/opus[1m]`). The mapping is one-way: the 1M variant and `opusplan` are
* not recoverable after the fact. So this seeds the FAMILY, which is right about which model answered and
* silent about the variant — and the first real turn afterwards replaces it with the exact alias.
*
* Compare the alternative, which is what this exists to prevent: with no row at all, resuming a session
* falls back to the first entry in the catalogue. That looked correct for as long as the first entry
* happened to be `opus`, and started showing every old conversation as `fable` the day `fable` was added
* above it.
*
* ── Rules it follows ──
*
* INSERT ONLY. `seedMissingChatSessionModels` never overwrites, so a session that has already recorded its
* exact alias keeps it, and re-running this is a no-op rather than a downgrade.
*
* NEVER FAILS THE PUSH. Every failure path exits 0 with a printed reason. A schema push that has already
* applied must not report failure because a cosmetic backfill could not read a transcript — and `postdb:push`
* failing would make `bun db:push` look like it had not worked.
*
* OWNER ONLY, for now. Members' transcripts are readable (`listTranscriptsAs` goes through `sudo`), but the
* platform's own user is not guaranteed to have that permission from a bare CLI invocation, and a member's
* sessions fill in on their next turn anyway. Stated here rather than silently scoped.
*/
import { listTranscriptModels } from '../src/servers/api/chat/claude-sessions';
import { officerModelForApiModel } from '../src/servers/api/chat/model-aliases';
import { chatSessionModelsTableExists, seedMissingChatSessionModels } from 'officerdb';
import { homedir } from 'node:os';
const done = (message: string): never => {
console.log(`[backfill:session-models] ${message}`);
process.exit(0);
};
try {
// The push may have been a no-op, or aimed at a database where this table is commented out of
// `src/schema.ts`. Checking beats assuming — and beats a confusing "relation does not exist".
if (!(await chatSessionModelsTableExists())) done('chat_session_models is not there yet — nothing to do');
const owner = { email: '', home: homedir(), osUser: null, isOwner: true };
const transcripts = listTranscriptModels(owner);
if (!transcripts.length) done('no transcripts found — nothing to do');
// Drop what cannot be named honestly: `<synthetic>` is filtered upstream, and mythos and OpenCode models
// have no Officer id. A dropped session shows no model rather than a wrong one.
const entries = transcripts.flatMap(({ sessionId, apiModel }) => {
const model = officerModelForApiModel(apiModel);
return model ? [{ sessionId, model }] : [];
});
const unmapped = transcripts.length - entries.length;
if (!entries.length) done(`${transcripts.length} transcript(s), none with a model Officer offers`);
const inserted = await seedMissingChatSessionModels(entries);
const skipped = entries.length - inserted;
done(
`${inserted} seeded` +
(skipped ? `, ${skipped} already recorded` : '') +
(unmapped ? `, ${unmapped} with no Officer equivalent` : '') +
` (of ${transcripts.length} transcript(s))`,
);
} catch (err) {
// Deliberately exit 0. See the header: the push has already applied, and this is bookkeeping.
done(`skipped — ${err instanceof Error ? err.message : String(err)}`);
}