From 62a4bc172b4368ee2b5993127efa3b28dbb16e6a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 08:37:17 +0100 Subject: [PATCH] Show Assistant provider fallback model switches --- .../v6/internal/subsystems/ai-runtime.md | 9 +++++++- .../src/components/AI/Chat/MessageItem.tsx | 23 +++++++++++++++++-- .../AI/Chat/__tests__/MessageItem.test.tsx | 22 ++++++++++++++++++ .../__tests__/streamEventGrouping.test.ts | 16 +++++++++++++ .../AI/Chat/__tests__/useChat.test.ts | 4 ++++ .../src/components/AI/Chat/hooks/useChat.ts | 7 +++++- .../src/components/AI/Chat/types.ts | 2 ++ 7 files changed, 79 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 762afeec0..5791b4d7c 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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, diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index a2fe9aec2..9d21937b6 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -99,6 +99,8 @@ export const MessageItem: Component = (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 = (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 = (props) => { /> + +
+
+
+ {/* Content/text block */} { 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(() => ( + + 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[] = [ { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts index e30ed89ae..044de1529 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts @@ -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); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index cefcbbd47..1931fd6e3 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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', diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index d80d315d6..f83fc97de 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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) { diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 90f34b8a9..9acb24e39 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -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