a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected.
This commit is contained in:
@@ -133,6 +133,23 @@ export function createTurnStream(sessionId: string): TurnStream {
|
||||
// notice belongs after it rather than in the middle of a paragraph it interrupted.
|
||||
return [...flushAll(), { msg: { type: 'compact:start', trigger: event.trigger }, durable: true }];
|
||||
|
||||
case 'model:switched':
|
||||
// Flushed for the same reason, and it matters more here: the marker's whole job is to say where in
|
||||
// the conversation the tier changed, so it must land after the last thing the old model said.
|
||||
return [
|
||||
...flushAll(),
|
||||
{
|
||||
msg: {
|
||||
type: 'model:switched',
|
||||
from: event.from,
|
||||
to: event.to,
|
||||
ok: event.ok,
|
||||
...(event.error ? { error: event.error } : {}),
|
||||
},
|
||||
durable: true,
|
||||
},
|
||||
];
|
||||
|
||||
case 'compact:done':
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user