record a resumed session's model when it switches, not when the turn ends

`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.

Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.

Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.

Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (ed3806f9). Written down because the shape
came out of a conversation, not out of the code.
This commit is contained in:
2026-08-17 17:17:52 +00:00
parent 813bc200bf
commit fc67973876
2 changed files with 124 additions and 3 deletions
+17 -3
View File
@@ -318,7 +318,7 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
}
// Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
function createMessageHandler(sessionId: string, model: string) {
function createMessageHandler(sessionId: string, model: string, resumeSessionId?: string) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -339,7 +339,19 @@ function createMessageHandler(sessionId: string, model: string) {
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;
/*
Seeded from the resume id, and that seed is what makes a switch durable.
This handler is built PER TURN, so without the seed `transcriptId` starts null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was still nothing to key the row on. A resumed session then recorded its model only on the closing
`result`, which means a turn that died took the switch with it: the sidecar log showed
`fable -> opus[1m]` while the stored row still said `opus`. Observed on this machine.
A brand-new conversation still has to wait for `session:claude` — it has no id yet by definition — but a
resumed one already knows, and resumed is the case where a switch actually happens.
*/
let transcriptId: string | null = resumeSessionId ?? null;
/**
* Write the model down, once both halves are known.
@@ -629,7 +641,9 @@ async function handleClaudeCodeChat(
session.isGenerating = true;
const onMessage = createMessageHandler(sessionId, model);
// The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather
// than when the turn ends — see the seed in `createMessageHandler`.
const onMessage = createMessageHandler(sessionId, model, msg.resumeSessionId);
/**
* One description of the turn, used by both paths below.