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:
@@ -312,10 +312,32 @@ 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) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
|
||||
`foldIntoSession` stamps every assistant message with this, so it has to track what the harness actually
|
||||
did rather than what the client requested. A tier switch is a control request on a live session and can
|
||||
be refused; when it is, the turn still runs and still answers — as the OLD model — and stamping it with
|
||||
the new one would put a lie in the transcript that nothing downstream could detect.
|
||||
|
||||
Mutable for that reason, and updated only from the harness's own `model:switched`.
|
||||
*/
|
||||
let effective = model;
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
foldIntoSession(session, msg, model);
|
||||
|
||||
if (msg.type === 'model:switched') {
|
||||
// `to` on success, `from` on refusal — either way, what is answering from here on.
|
||||
effective = msg.ok ? msg.to : (msg.from ?? effective);
|
||||
// The server's record follows the harness, never the request: `handleStop`, `endTurnIfAgentIsGone`
|
||||
// and the orphan adopter all route on `session.model`, and a stale one sends a stop to the wrong
|
||||
// sidecar's kill path.
|
||||
sessionManager.setModel(sessionId, effective);
|
||||
}
|
||||
|
||||
foldIntoSession(session, msg, effective);
|
||||
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
||||
};
|
||||
}
|
||||
@@ -413,7 +435,33 @@ async function handleChat(
|
||||
: withSummary;
|
||||
|
||||
// Use provided model, or fall back to user default, or the system default.
|
||||
const model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
||||
const requested = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
||||
|
||||
/*
|
||||
A conversation may change TIER mid-flight; it may not change HARNESS.
|
||||
|
||||
Tier is a control request on the live session (`setModel`), so opus → sonnet costs nothing and keeps the
|
||||
transcript. Harness is a different sidecar with a different session id space, and crossing it is what
|
||||
actually broke: an OpenCode `ses_…` id handed to `claude --resume`, which reads as a dead agent rather
|
||||
than as a wrong dispatch. Nothing refused it before — the UI simply locked the whole selector after the
|
||||
first message, which also blocked the harmless half.
|
||||
|
||||
The server's own record wins here, the same precedence `decideResume` uses: a session in memory already
|
||||
knows its harness, and a socket does not get to re-declare it.
|
||||
*/
|
||||
const live = sessionManager.getSession(sessionId);
|
||||
let model = requested;
|
||||
if (live && isClaudeModel(live.model) !== isClaudeModel(requested)) {
|
||||
model = live.model;
|
||||
logger.warn('Refused a harness change mid-session', { sessionId, requested, keeping: live.model });
|
||||
sendToClient(ws, {
|
||||
type: 'model:switched',
|
||||
from: live.model,
|
||||
to: requested,
|
||||
ok: false,
|
||||
error: 'A conversation cannot move between Claude Code and OpenCode. Start a new chat for that.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user