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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user