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:
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
setChatSessionModel,
|
||||
getChatSessionModel,
|
||||
getChatSessionModels,
|
||||
seedMissingChatSessionModels,
|
||||
chatSessionModelsTableExists,
|
||||
} from './queries';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { chatSessionModels } from './schema';
|
||||
|
||||
/**
|
||||
* Record the model a session is running as. Upsert — one row per session, last write wins.
|
||||
*
|
||||
* Called with the EFFECTIVE model, never the requested one: a tier switch the harness refuses leaves the
|
||||
* conversation on the old model, and storing what was asked for would put a value here that disagrees with
|
||||
* the agent that answered. That is the same distinction the per-message attribution makes in `websocket.ts`,
|
||||
* and it is the reason this takes a plain string rather than reading the client's message itself.
|
||||
*/
|
||||
export async function setChatSessionModel(sessionId: string, model: string): Promise<void> {
|
||||
await db
|
||||
.insert(chatSessionModels)
|
||||
.values({ sessionId, model })
|
||||
.onConflictDoUpdate({
|
||||
target: chatSessionModels.sessionId,
|
||||
set: { model, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/** One session's stored model, or null when nothing has recorded one yet. */
|
||||
export async function getChatSessionModel(sessionId: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ model: chatSessionModels.model })
|
||||
.from(chatSessionModels)
|
||||
.where(inArray(chatSessionModels.sessionId, [sessionId]))
|
||||
.limit(1);
|
||||
return row?.model ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored models for a page of sessions, as a Map.
|
||||
*
|
||||
* Batched on purpose: the session list renders a model per row, and the per-row alternative is one query
|
||||
* per session on a screen that routinely shows a hundred of them. Returns an empty Map for an empty input
|
||||
* rather than issuing `IN ()`, which Postgres rejects.
|
||||
*/
|
||||
export async function getChatSessionModels(sessionIds: string[]): Promise<Map<string, string>> {
|
||||
if (!sessionIds.length) return new Map();
|
||||
const rows = await db
|
||||
.select({ sessionId: chatSessionModels.sessionId, model: chatSessionModels.model })
|
||||
.from(chatSessionModels)
|
||||
.where(inArray(chatSessionModels.sessionId, sessionIds));
|
||||
return new Map(rows.map((r) => [r.sessionId, r.model]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed models for sessions that have none, and leave every existing row alone.
|
||||
*
|
||||
* For the backfill, which infers a FAMILY (`claude-code/opus`) from the transcript because that is all the
|
||||
* transcript can support. `onConflictDoNothing` is what makes it safe to re-run and safe to run late: a
|
||||
* session that has since taken a real turn already holds the exact alias the user picked, possibly
|
||||
* `opus[1m]`, and a family guess must never overwrite it.
|
||||
*
|
||||
* Returns how many rows were actually inserted, so the script can say what it did rather than claim
|
||||
* success for a no-op.
|
||||
*/
|
||||
export async function seedMissingChatSessionModels(entries: { sessionId: string; model: string }[]): Promise<number> {
|
||||
if (!entries.length) return 0;
|
||||
const rows = await db
|
||||
.insert(chatSessionModels)
|
||||
.values(entries)
|
||||
.onConflictDoNothing({ target: chatSessionModels.sessionId })
|
||||
.returning({ sessionId: chatSessionModels.sessionId });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/** Whether the table exists yet — the backfill runs from `postdb:push` and must not assume push succeeded. */
|
||||
export async function chatSessionModelsTableExists(): Promise<boolean> {
|
||||
const rows = await db.execute<{ present: boolean }>(
|
||||
sql`select to_regclass('chat_session_models') is not null as present`,
|
||||
);
|
||||
const first =
|
||||
(rows as unknown as { rows?: { present: boolean }[] }).rows?.[0] ?? (rows as never as { present: boolean }[])[0];
|
||||
return first?.present === true;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Which model each chat session is using — the one fact about a session the transcript cannot answer.
|
||||
*
|
||||
* ── Why this exists at all ──
|
||||
*
|
||||
* Everything else the session list shows (title, cwd, timestamps, message count) is read back out of
|
||||
* Claude's own transcript on disk. The model is recorded there too — every assistant entry carries a
|
||||
* `message.model` — but in the WRONG ID SPACE and lossily:
|
||||
*
|
||||
* the transcript says Officer offers
|
||||
* claude-opus-5 claude-code/opus AND claude-code/opus[1m]
|
||||
* claude-sonnet-5 claude-code/sonnet claude-code/sonnet[1m]
|
||||
* (whichever answered) claude-code/opusplan
|
||||
*
|
||||
* `opus` and `opus[1m]` are indistinguishable after the fact, and `opusplan` records whichever model
|
||||
* happened to answer that turn. So no mapping can round-trip what the user actually picked — which is
|
||||
* exactly the field a resumed session needs in order to restore its picker.
|
||||
*
|
||||
* The symptom: resume a session and the picker fell back to the first entry in the catalogue. That looked
|
||||
* fine for as long as the first entry was `opus`, and became visible the day `fable` was added above it.
|
||||
*
|
||||
* ── Why a table of its own ──
|
||||
*
|
||||
* There is no `chat_sessions` table and deliberately so: a session IS its transcript, and a row that also
|
||||
* held title and cwd would be a second source of truth for two fields that already have one. This table
|
||||
* claims only the field the transcript cannot express.
|
||||
*
|
||||
* The alternative was to derive it from `chat_session_events`, which does now record `model:switched`.
|
||||
* Correct data, wrong index: that table holds every assistant message and is unbounded, so asking it for
|
||||
* the latest model across a list of sessions scans far more than the question deserves. This is the same
|
||||
* data keyed for the query the list actually runs — one row per session, fetched for a whole page at once.
|
||||
*/
|
||||
export const chatSessionModels = pgTable('chat_session_models', {
|
||||
/**
|
||||
* The session's transcript uuid — Claude's own id, globally unique, so no user scoping is needed.
|
||||
*
|
||||
* The natural key rather than a `serial`: there is exactly one current model per session, and the write
|
||||
* path is an upsert on every turn. A surrogate id would add a column and a unique index to express the
|
||||
* constraint the primary key already is.
|
||||
*/
|
||||
sessionId: text('session_id').primaryKey(),
|
||||
/**
|
||||
* OFFICER's model id (`claude-code/opus[1m]`), not the API's (`claude-opus-5`).
|
||||
*
|
||||
* That is the whole point of the table — this is the string the picker offers and the sidecar resolves,
|
||||
* so it round-trips exactly. Text rather than an enum because the alias set changes with the CLI, and a
|
||||
* new model must never be a schema migration.
|
||||
*/
|
||||
model: text('model').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export * from './plugins';
|
||||
export * from './auth';
|
||||
export * from './permissions';
|
||||
export * from './chat-events';
|
||||
export * from './chat-session-models';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
export * from './pipeline-jobs';
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from './dashboards/schema'; // dashboards, screens, dashboard_defaults
|
||||
export * from './server/schema'; // server_config (SMTP lives here), server_integrations
|
||||
export * from './pipeline-jobs/schema'; // pipeline_jobs
|
||||
export * from './chat-events/schema'; // chat_session_events
|
||||
export * from './chat-session-models/schema'; // chat_session_models
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
Reference in New Issue
Block a user