mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 04:07:16 +00:00
Show Assistant provider fallback model switches
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user