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:
2026-08-17 13:54:08 +00:00
parent 0b7f13cadc
commit 4c24df93c1
12 changed files with 391 additions and 26 deletions
+19
View File
@@ -65,6 +65,25 @@ class SessionManager {
return this.sessions.get(sessionId) || null;
}
/**
* Record that a live session moved onto a different model.
*
* `getOrCreate` takes a `model` and, for a session that already exists, silently discards it — which was
* correct while the model could not change, and became a lie the moment it could. Everything that routes
* or attributes by `session.model` reads this: `handleStop`, `endTurnIfAgentIsGone`, the orphan adopter,
* and the cost fold. Leaving it stale meant a stop landing on the wrong harness's kill path.
*
* Called only AFTER the harness confirms the switch. A refused switch must leave this alone, or the
* server's record and the running agent disagree in the direction that is hardest to notice.
*/
setModel(sessionId: string, model: string): void {
const session = this.sessions.get(sessionId);
if (!session || session.model === model) return;
session.model = model;
session.meta.model = model;
session.meta.updatedAt = Date.now();
}
getUserSessions(email: string): UserSession[] {
const sessionIds = this.userSessions.get(email) || [];
return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined);
+18 -3
View File
@@ -234,7 +234,19 @@ export type ServerMessage =
// these the stream simply stops, which is indistinguishable from a hung turn, a dead socket or a
// crashed agent; the honest response to that is to restart the server, which is what actually happened.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
/*
The conversation changed model, mid-conversation.
Durable, and that is the point rather than a nicety: without a record in the log, history renders every
message of a switched session under whichever tier it currently reports, so a transcript half-answered
by opus reads as entirely sonnet. `claude-sessions.ts` reads the FIRST model it sees in a transcript, so
it cannot see a switch at all.
`ok: false` is a switch the harness refused. It is not an error message — the turn still ran and still
answered, as `from` — so it says what actually happened rather than what was asked for.
*/
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
@@ -256,7 +268,8 @@ export type TurnMessageType =
| 'task:started'
| 'task:notification'
| 'compact:start'
| 'compact:done';
| 'compact:done'
| 'model:switched';
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
@@ -290,7 +303,9 @@ export type ChatEvent =
// Compaction, start and end. The start half has no message in the harness's output stream at all — it
// comes from the `PreCompact` hook, which is why it is a sidecar concern and not the parser's.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
// Emitted by `applyModelChange` when a live session moves onto a different tier — see claude-manager.
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
export type UserSession = {
sessionId: string;
+50 -2
View File
@@ -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 });
@@ -220,6 +220,16 @@ type PersistentSession = {
interrupted: boolean;
/** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */
resumeId?: string;
/**
* The model this session is currently answering as, `harness/tier`.
*
* Recorded because `model` reaches the SDK exactly once, in `createSession`'s `query()` options, while
* every later turn is pushed onto that same call — so without this there is nothing to compare a turn's
* requested model against, and a change was silently dropped. Officer's UI, its cost folding and its
* durable log all took the client's word for it, so a switch produced a session that reported one tier
* and answered as another.
*/
model?: string;
/** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */
stderrTail: string;
/** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */
@@ -468,6 +478,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
const subModel = params.model?.split('/')[1];
session.resumeId = resumeId;
session.model = params.model;
/**
* What we are about to run, before we run it.
@@ -662,12 +673,48 @@ export async function spawnClaudeStreaming(
}
if (session) {
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
await applyModelChange(session, params.model);
} else {
session = createSession(params, onEvent);
}
session.pushTurn(params.prompt, params.images);
}
/**
* Move a LIVE session onto a different model, the way the CLI's `/model` does.
*
* `model` is a `query()` option and options are read once, so before this a changed model was dropped on
* the floor — the session kept answering as whatever it started as while Officer's UI, its cost folding and
* its durable log all reported the new tier. The mismatch was invisible: no log line, no error, and the one
* way to trigger it was to leave a chat idle past the 30-minute GC.
*
* `setModel` is a control request, so it needs streaming input mode — which is the only mode this file uses.
* That is why nothing has to be torn down and re-resumed: the conversation, its cwd, its permission mode and
* its transcript all continue, and only the next response comes from a different model.
*
* A refusal is reported and swallowed. The turn the user just typed is worth more than the tier they picked,
* so a `setModel` the harness rejects must not lose the prompt — it answers as the old model and says so.
*/
async function applyModelChange(session: PersistentSession, model: string | undefined): Promise<void> {
if (!model || model === session.model) return;
const previous = session.model;
const subModel = model.split('/')[1];
if (!subModel) return;
try {
await session.query.setModel(subModel);
session.model = model;
console.log(`[claude:model:${session.sessionKey}] ${previous ?? 'default'} -> ${model}`);
session.emit({ type: 'model:switched', from: previous ?? null, to: model, ok: true });
} catch (err) {
// Leave `session.model` alone, so the next turn tries again rather than believing a switch that failed.
const reason = err instanceof Error ? err.message : String(err);
console.error(`[claude:model:${session.sessionKey}] refused ${previous ?? 'default'} -> ${model}: ${reason}`);
session.emit({ type: 'model:switched', from: previous ?? null, to: model, ok: false, error: reason });
}
}
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
const session = ownedSession(sessionKey, userId);
@@ -170,6 +170,33 @@ describe('createTurnStream', () => {
]);
});
// A tier switch has to survive a reload, and it has to land in the right PLACE. The marker says "opus
// wrote everything above this line", which is false if it is emitted before the last thing opus said is
// flushed out of the delta buffer.
test('a model switch is durable and lands after the previous model’s last words', () => {
const { durable } = run([
{ type: 'delta', text: 'the old model speaking' },
{ type: 'model:switched', from: 'claude-code/opus', to: 'claude-code/sonnet', ok: true },
{ type: 'text', text: 'the new one' },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'the old model speaking' },
{ type: 'model:switched', from: 'claude-code/opus', to: 'claude-code/sonnet', ok: true },
{ type: 'assistant:text', text: 'the new one' },
]);
});
test('a refused switch is recorded too, with its reason', () => {
// Silence here would be the worst outcome: the turn answers as the OLD model while the picker shows the
// new one, which is the exact mismatch this whole change exists to remove.
const { durable } = run([
{ type: 'model:switched', from: 'claude-code/opus', to: 'claude-code/haiku', ok: false, error: 'nope' },
]);
expect(durable).toEqual([
{ type: 'model:switched', from: 'claude-code/opus', to: 'claude-code/haiku', ok: false, error: 'nope' },
]);
});
test('streams are independent', () => {
const a = createTurnStream('a');
const b = createTurnStream('b');
+17
View File
@@ -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 [
{
@@ -15,6 +15,7 @@ import {
PlugZap,
RotateCcw,
FoldVertical,
ArrowLeftRight,
} from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import type { Tone } from '@/components/Data';
@@ -211,6 +212,9 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
case 'compact':
return <CompactActivity message={message} />;
case 'model':
return <ModelSwitch message={message} />;
case 'interrupted':
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
@@ -340,6 +344,44 @@ const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'c
);
};
/**
* Where the conversation changed model.
*
* The same centred rule as `compact` and `interrupted`, because it is the same kind of thing: a landmark
* about the conversation rather than work inside a turn. It reads as a boundary — everything above it came
* from one model, everything below from another — which is the only question this row exists to answer.
*
* A refusal is muted rather than red. The turn ran and answered; what failed was a preference, and dressing
* that in destructive chrome would say something went wrong with the work.
*/
const ModelSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'model' }> }) => {
// `claude-code/opus` → `opus`. The harness is pinned for the life of a conversation, so repeating it on
// every marker is noise; the tier is the part that changed.
const tier = (id: string | null) => id?.split('/').pop() ?? 'default';
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<ArrowLeftRight className="h-3 w-3" />
{message.ok ? (
<>
{tier(message.from)} <span className="opacity-50">→</span> {tier(message.to)}
</>
) : (
<>
Kept {tier(message.from)}
<span className="normal-case opacity-70">
· {message.error ?? `could not switch to ${tier(message.to)}`}
</span>
</>
)}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
};
/**
* The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
@@ -0,0 +1,81 @@
import { describe, expect, test } from 'bun:test';
import { render, screen } from '@testing-library/react';
import type { ModelOption } from 'state/useModels';
import { ModelSelector } from './ModelSelector';
/**
* Tier is changeable mid-conversation; harness is not.
*
* The selector used to lock entirely on `hasStarted`, which blocked both. Nothing justified it — the commit
* that added the flag says only "default model" — and the hazard it resembles is narrower than what it
* stopped: crossing harnesses hands an OpenCode `ses_…` id to `claude --resume`, which surfaces as a dead
* agent rather than a wrong dispatch. Changing tier is a control request on the live session and always was
* safe.
*
* These two pull in opposite directions, so a test for either alone passes with the other broken: unlock
* everything and the harness test fails; restore the old blanket lock and the tier test fails.
*/
const MODELS: ModelOption[] = [
{ id: 'claude-code/opus', name: 'opus', provider: 'claude-code' },
{ id: 'claude-code/sonnet', name: 'sonnet', provider: 'claude-code' },
// Two OpenCode providers, so the "other harness" case has more than one thing to choose between —
// otherwise the strip collapses for want of options and the test would pass for the wrong reason.
{ id: 'anthropic/claude-3-5', name: 'claude-3-5', provider: 'anthropic' },
{ id: 'openai/gpt-5', name: 'gpt-5', provider: 'openai' },
] as ModelOption[];
const mount = (over: Partial<Parameters<typeof ModelSelector>[0]> = {}) =>
render(
<ModelSelector
messages={[]}
availableModels={MODELS}
selectedModel="claude-code/opus"
onModelChange={() => {}}
model={null}
isConnected
isGenerating={false}
hasStarted
{...over}
/>,
);
const providerButton = (name: RegExp) => screen.getByRole('button', { name });
describe('ModelSelector after a conversation has started', () => {
test('the tier select is enabled', () => {
mount();
// The `<Select>` renders its trigger as a combobox. Enabled is the whole claim.
expect(screen.getByRole('combobox').hasAttribute('disabled')).toBe(false);
});
test('a Claude Code chat shows its harness as a label, not a choice', () => {
// Every other provider belongs to the other harness, so there is nothing left to pick between and the
// strip collapses — which is the same shape the old blanket lock produced, reached for a real reason.
mount();
expect(screen.queryByRole('button', { name: /claude code/i })).toBeNull();
expect(screen.getByText('Claude Code')).toBeTruthy();
});
test('an OpenCode chat can move between providers but not to Claude Code', () => {
mount({ selectedModel: 'anthropic/claude-3-5' });
expect(providerButton(/^openai$/i).hasAttribute('disabled')).toBe(false);
const crossing = providerButton(/claude code/i);
expect(crossing.hasAttribute('disabled')).toBe(true);
expect(crossing.getAttribute('title')).toMatch(/cannot move between/i);
});
test('generating locks everything, harness and tier alike', () => {
// A turn in flight is a different reason from "already started", and it still has to win: `setModel`
// mid-response is not what the control offers.
mount({ isGenerating: true });
expect(screen.getByRole('combobox').hasAttribute('disabled')).toBe(true);
expect(screen.queryByRole('button', { name: /claude code/i })).toBeNull();
});
test('before the first message, either harness is fair game', () => {
mount({ hasStarted: false });
expect(providerButton(/anthropic/i).hasAttribute('disabled')).toBe(false);
expect(providerButton(/claude code/i).hasAttribute('disabled')).toBe(false);
});
});
@@ -71,11 +71,37 @@ export function ModelSelector({
}
}, [selectedModel, model, fallbackModelId]);
// Lock after session has started
const isLocked = hasStarted || isGenerating || !isConnected;
/*
Two different questions, which this used to answer with one flag.
`isLocked` was `hasStarted || isGenerating || !isConnected`, so a conversation could never change model
at all. Nothing justified the `hasStarted` part — the commit that added it says only "default model" —
and the hazard it looks like it was guarding is narrower than what it blocked: switching HARNESS
mid-session hands a `ses_…` id to `claude --resume` and dispatches to the wrong sidecar (see
ChatDetailPanel's note on `harness/model`). Switching TIER is a different thing and always was.
So: tier is free once started, harness is pinned. Claude Code's own `/model` behaves this way, and the
SDK exposes it as a control request on the live session, so the server can honour it without restarting
anything.
*/
const isBusy = isGenerating || !isConnected;
const harnessOf = (id: string) => (id.startsWith('claude-code') ? 'claude-code' : 'opencode');
const currentHarness = displayModel ? harnessOf(displayModel) : null;
/** Would picking this provider move the conversation to the other sidecar? */
const crossesHarness = (provider: string) => {
if (!hasStarted || !currentHarness) return false;
const target = availableModels.find((m) => m.provider === provider);
return !!target && harnessOf(target.id) !== currentHarness;
};
// The provider strip still collapses to a label when there is nothing to choose between — but now that
// means "one harness on offer", not "you have already sent a message".
const selectableProviders = providers.filter((p) => !crossesHarness(p));
const providerStripLocked = isBusy || selectableProviders.length < 2;
const handleProviderClick = (provider: string) => {
if (isLocked) return;
if (isBusy || crossesHarness(provider)) return;
const firstModel = availableModels.find((m) => m.provider === provider);
if (firstModel) onModelChange(firstModel.id);
};
@@ -99,7 +125,7 @@ export function ModelSelector({
return (
<div className="mt-2 flex items-center justify-between gap-2">
{isLocked ? (
{providerStripLocked ? (
// A static label, not an action — `bg-muted` rather than a filled accent. It used to be
// `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was white
// on white for every session that had already started, i.e. almost always.
@@ -110,19 +136,26 @@ export function ModelSelector({
// Fifteen providers can be configured and the names are long ("Google Vertex AI"), so the strip
// scrolls rather than crushing the model select beside it.
<div className="flex min-w-0 items-center gap-1 overflow-x-auto rounded-lg bg-background/60 p-1">
{providers.map((provider) => (
<button
key={provider}
onClick={() => handleProviderClick(provider)}
className={`shrink-0 rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeProvider === provider
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{displayName(provider)}
</button>
))}
{providers.map((provider) => {
const blocked = crossesHarness(provider);
return (
<button
key={provider}
onClick={() => handleProviderClick(provider)}
disabled={blocked}
title={blocked ? 'A conversation cannot move between Claude Code and OpenCode' : undefined}
className={`shrink-0 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
blocked ? 'cursor-default opacity-30' : 'cursor-pointer'
} ${
activeProvider === provider
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{displayName(provider)}
</button>
);
})}
</div>
)}
{/* Background job. Sits between the harness (left) and the model (right), in the gap
@@ -162,8 +195,8 @@ export function ModelSelector({
{providerModels.length > 0 ? (
<Select
value={displayModel ?? fallbackModelId ?? undefined}
onValueChange={(v) => !isLocked && onModelChange(v)}
disabled={isLocked}
onValueChange={(v) => !isBusy && onModelChange(v)}
disabled={isBusy}
>
<SelectTrigger className="h-auto cursor-pointer gap-1 border-0 bg-transparent p-0 text-xs text-muted-foreground shadow-none focus:ring-0">
<SelectValue />
@@ -18,7 +18,9 @@ import type { ChatMessage } from '../types';
/** Rows that are landmarks about the conversation rather than work done inside a turn. These never fold. */
function isStructural(message: ChatMessage): boolean {
return message.role === 'divider' || message.role === 'compact';
// `model` belongs here for the same reason `compact` does: it is the answer to "which half of this
// conversation did opus write", which is worthless folded away inside a turn summary.
return message.role === 'divider' || message.role === 'compact' || message.role === 'model';
}
/** The work of a turn: everything that belongs behind the summary row. */
@@ -88,6 +88,22 @@ export type ChatMessage =
preTokens?: number;
durationMs?: number;
done?: boolean;
}
| {
// Where in the conversation the model changed.
//
// A marker rather than a property of the messages around it, because the interesting thing is the
// BOUNDARY: which half of this conversation is opus and which is sonnet. Per-message model is already
// stamped server-side and shown nowhere, so without a row in the transcript a switched session reads
// as though one model wrote all of it.
//
// `ok: false` is a switch that was refused — by the harness, or by the server when it would have
// crossed harnesses. The turn still ran, as `from`, so the row says that rather than showing an error.
role: 'model';
from: string | null;
to: string;
ok: boolean;
error?: string;
};
export type RunningTask = { taskId: string; description: string; taskType?: string };
@@ -149,7 +165,9 @@ export type ServerMessage =
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
/** Context compaction beginning and ending. See the `compact` message role. */
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
/** The conversation moved onto a different model. See the `model` message role. */
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
export type Message = {
id: string;
@@ -467,6 +467,22 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setMessages((prev) => [...prev, { role: 'compact', trigger: msg.trigger }]);
break;
case 'model:switched':
// Committed for the same reason and it matters more: the marker's job is to say where the tier
// changed, so anything the previous model had already streamed has to land above it.
commitStreaming();
setMessages((prev) => [
...prev,
{ role: 'model', from: msg.from, to: msg.to, ok: msg.ok, ...(msg.error ? { error: msg.error } : {}) },
]);
// A refusal leaves the conversation on the old model, so the picker has to go back — otherwise it
// keeps showing a tier that is not answering, and every later turn re-requests it.
if (!msg.ok && msg.from) {
setSelectedModel(msg.from);
modelRef.current = msg.from;
}
break;
case 'compact:done':
setMessages((prev) => {
const at = prev.findLastIndex((m) => m.role === 'compact' && !m.done);