Show Assistant provider fallback model switches

This commit is contained in:
rcourtman
2026-06-06 08:37:17 +01:00
parent 8e2e60fcc3
commit 62a4bc172b
7 changed files with 79 additions and 4 deletions
@@ -188,7 +188,14 @@ runtime cost control, and shared AI transport surfaces.
completed the turn, and the drawer must update the in-flight transcript row
when `provider_fallback` names the next route so message labels, cost
context, retry decisions, and model-route recovery do not continue to point
at the failed provider.
at the failed provider. The referenced OpenCode source at fetched
`origin/dev` commit `1399323b78a04229d9bfe00c7436d7f41770fda8` renders
`ModelSwitchedMessage` as a typed transcript message and appends the
effective provider/model route to completed assistant turns in
`packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx`.
Pulse adapts that by rendering provider fallback route changes as a typed
`model_switch` stream row on the active assistant turn instead of hiding the
switch in transient status text or assistant prose.
Interactive Assistant streams must establish the session ID and emit the
`session` event once as soon as the HTTP SSE writer is ready, before finding
handoff recovery, model resolution, provider fallback planning,
@@ -99,6 +99,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
return !!evt.tool;
case 'pending_tool':
return !!evt.pendingTool;
case 'model_switch':
return !!evt.model?.trim();
case 'approval':
return !!evt.approval;
case 'question':
@@ -127,11 +129,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
});
const visibleMessageContent = () =>
stripAssistantOutputArtifacts(props.message.content || '').text;
const messageModelLabel = () => {
const model = props.message.model?.trim();
const modelRouteLabel = (route?: string) => {
const model = route?.trim();
if (!model) return '';
return props.getModelRouteLabel?.(model) || formatAIModelRouteLabel(model);
};
const messageModelLabel = () => modelRouteLabel(props.message.model);
// Check if currently streaming content (no tools pending, still streaming)
const isStreamingText = () =>
@@ -346,6 +349,22 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
/>
</Match>
<Match when={evt.type === 'model_switch' && evt.model?.trim()}>
<div
class="my-2 inline-flex max-w-full items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
role="status"
aria-label="Assistant model route changed"
title={evt.model}
>
<CpuIcon class="h-3.5 w-3.5 shrink-0 text-blue-500" aria-hidden="true" />
<span class="shrink-0">Switched to</span>
{' '}
<span class="truncate font-medium text-base-content">
{modelRouteLabel(evt.model)}
</span>
</div>
</Match>
{/* Content/text block */}
<Match
when={
@@ -626,6 +626,28 @@ describe('MessageItem', () => {
expect(screen.queryByText(/Hidden reasoning/i)).not.toBeInTheDocument();
});
it('renders provider fallback model switches as typed transcript status', () => {
const events: StreamDisplayEvent[] = [
{ type: 'model_switch', model: 'openrouter:deepseek/deepseek-v4-pro' },
];
render(() => (
<MessageItem
message={makeMessage({ role: 'assistant', streamEvents: events })}
getModelRouteLabel={(model) =>
model === 'openrouter:deepseek/deepseek-v4-pro'
? 'DeepSeek V4 Pro via OpenRouter'
: model
}
{...makeHandlers()}
/>
));
expect(
screen.getByRole('status', { name: 'Assistant model route changed' }),
).toHaveTextContent('Switched to DeepSeek V4 Pro via OpenRouter');
});
it('renders tool execution blocks', () => {
const events: StreamDisplayEvent[] = [
{
@@ -58,6 +58,22 @@ describe('groupStreamEventsForDisplay', () => {
expect(grouped[2].content).toBe('All healthy.');
});
it('keeps content separated across a model-switch boundary', () => {
const modelSwitch: StreamDisplayEvent = {
type: 'model_switch',
model: 'openrouter:deepseek/deepseek-v4-pro',
};
const grouped = groupStreamEventsForDisplay([
content('OpenRouter was unavailable.'),
modelSwitch,
content('Trying the gateway route now.'),
]);
expect(grouped.map((e) => e.type)).toEqual(['content', 'model_switch', 'content']);
expect(grouped[0].content).toBe('OpenRouter was unavailable.');
expect(grouped[2].content).toBe('Trying the gateway route now.');
});
it('skips empty deltas', () => {
const grouped = groupStreamEventsForDisplay([content(''), thinking(''), content('hi')]);
expect(grouped).toHaveLength(1);
@@ -1091,6 +1091,10 @@ describe('useChat', () => {
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
expect(assistant.model).toBe('gemini:gemini-3.1-flash-lite');
expect(assistant.streamEvents).toContainEqual({
type: 'model_switch',
model: 'gemini:gemini-3.1-flash-lite',
});
expect(assistant.workflowStatus).toEqual(
expect.objectContaining({
phase: 'provider_fallback',
@@ -692,7 +692,12 @@ export function useChat(options: UseChatOptions = {}) {
if (nextModel) {
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, model: nextModel } : msg)),
prev.map((msg) => {
if (msg.id !== assistantId) return msg;
if ((msg.model || '').trim() === nextModel) return msg;
const updated = addStreamEvent(msg, { type: 'model_switch', model: nextModel });
return { ...updated, model: nextModel };
}),
);
}
if (workflowStatus) {
@@ -97,6 +97,7 @@ export type StreamEventType =
| 'tool'
| 'content'
| 'pending_tool'
| 'model_switch'
| 'approval'
| 'question';
@@ -106,6 +107,7 @@ export interface StreamDisplayEvent {
tool?: ToolExecution;
pendingTool?: PendingTool;
content?: string;
model?: string;
toolId?: string; // Used to match pending_tool with completed tool
approval?: PendingApproval; // For approval_needed events
question?: PendingQuestion; // For question events