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.
53 lines
2.8 KiB
TypeScript
53 lines
2.8 KiB
TypeScript
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]');
|
|
});
|
|
});
|