Clarify Assistant route recovery actions

This commit is contained in:
rcourtman
2026-06-07 20:08:29 +01:00
parent 2e486fbbf9
commit fa50307212
10 changed files with 68 additions and 23 deletions
@@ -454,7 +454,7 @@ remain visible/inspectable.
treating provider/model changes as explicit session events in
`packages/core/src/session/event.ts` and
`packages/opencode/src/session/prompt.ts`. Pulse adapts that behavior for the
drawer instead of implementing automatic cross-provider fallback.
drawer instead of implementing automatic cross-provider route switching.
Primary interactive chat model resolution must use the explicit configured
chat route or a stable provider default without calling provider model
catalogs before the selected stream starts. Catalog-backed recommendation
@@ -484,8 +484,8 @@ remain visible/inspectable.
they come from explicit route recovery or restored historical data, but the UI
must label them neutrally as selected or switched routes rather than as
automatic provider fallback, and completing a streamed route-switch row must
not promote a fallback route into the active model selection without an
explicit user action.
not promote that route into the active model selection without an explicit
user action.
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, selected-provider startup,
@@ -2156,6 +2156,20 @@ autocomplete hides unavailable local commands, command help may show them
disabled with a reason, and manual slash submissions respect the same
availability before running local session actions.
Assistant provider-route recovery must read as explicit operator action, not
automatic fallback. The OpenCode reference at fetched `origin/dev` commit
`914a643` retries the selected provider/model route through
`packages/opencode/src/session/retry.ts` and
`packages/opencode/src/session/processor.ts` by passing
`input.model.providerID` into `SessionRetry.policy`, renders retry state in
`packages/ui/src/components/session-retry.tsx`, and records provider/model
changes as explicit `ModelSwitched` session events in
`packages/core/src/session/event.ts` plus `packages/opencode/src/session/prompt.ts`.
Pulse adapts that by keeping same-route retry visible, ignoring obsolete
provider route-switch metadata without changing the selected route, and labeling
failed-turn or readiness recovery buttons as explicit route/model-route choices
instead of implying automatic route adoption.
Assistant tool activity now follows an OpenCode-referenced chronological row
model where appropriate for Pulse. Consecutive context/read/query tools render
as visible transcript rows in arrival order instead of being replaced by a
@@ -2247,8 +2261,9 @@ the prompt and active/retry/interrupt state in the prompt footer in
`packages/tui/src/component/prompt/index.tsx`, with broader system status kept
in `packages/tui/src/routes/session/footer.tsx`. Pulse adapts that pattern for
the web drawer by keeping model route, recent-route cycling, control mode,
last-turn usage, active workflow progress, queued follow-ups, fallback notices,
and the autonomous control warning in the input-adjacent composer/status rail.
last-turn usage, active workflow progress, queued follow-ups, route-recovery
notices, and the autonomous control warning in the input-adjacent
composer/status rail.
Those items stay visible and actionable, but they do not compete with the
transcript as separate top-of-drawer banners unless they are provider readiness
or scoped handoff context surfaces with their own governed content.
@@ -360,6 +360,13 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
if (!model) return '';
return props.getModelRouteLabel?.(model) || formatAIModelRouteLabel(model);
};
const modelRouteRecoveryButtonLabel = () => {
const alternative = props.modelRouteAlternative;
if (!alternative) return '';
return alternative.kind === 'same-model-route'
? `Retry with ${alternative.providerLabel} route`
: `Retry with ${alternative.providerLabel} model route`;
};
const hasPreviousModelRoute = (event: StreamDisplayEvent) => {
const model = event.model?.trim();
const failed = event.failedModel?.trim();
@@ -804,12 +811,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
onClick={() =>
props.onUseModelRoute?.(alternative().id, props.message.id)
}
aria-label={`Retry via ${alternative().providerLabel} provider route`}
aria-label={modelRouteRecoveryButtonLabel()}
title={alternative().label}
class="inline-flex max-w-[14rem] items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
>
<CpuIcon class="h-3.5 w-3.5" />
<span class="truncate">Retry via {alternative().providerLabel}</span>
<span class="truncate">{modelRouteRecoveryButtonLabel()}</span>
</button>
)}
</Show>
@@ -288,7 +288,9 @@ vi.mock('../ChatMessages', () => ({
props.onUseModelRoute?.(recovery().alternative.id, recovery().failedMessage.id)
}
>
Retry via {recovery().alternative.providerLabel}
{recovery().alternative.kind === 'same-model-route'
? `Retry with ${recovery().alternative.providerLabel} route`
: `Retry with ${recovery().alternative.providerLabel} model route`}
</button>
)}
</Show>
@@ -853,6 +855,13 @@ describe('AIChat', () => {
fireEvent.click(await screen.findByTestId('mock-use-model-route'));
const props = mockChatMessagesProps[mockChatMessagesProps.length - 1];
const alternative = props.getModelRouteAlternative?.(mockChat.messages()[0]);
expect(alternative).toMatchObject({
id: 'openrouter:deepseek/deepseek-v4-pro',
kind: 'same-model-route',
provider: 'openrouter',
});
expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro');
expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-1', {
model: 'openrouter:deepseek/deepseek-v4-pro',
@@ -917,7 +926,7 @@ describe('AIChat', () => {
expect(screen.queryByTestId('mock-use-model-route')).not.toBeInTheDocument();
});
it('falls back to another configured provider after equivalent routes have already failed', async () => {
it('offers an explicit alternate model route after equivalent routes have already failed', async () => {
const openRouterFailure: ChatMessage = {
id: 'assistant-error-openrouter',
role: 'assistant',
@@ -980,6 +989,7 @@ describe('AIChat', () => {
expect(alternative).toMatchObject({
id: 'openai:gpt-4o',
kind: 'alternate-model-route',
provider: 'openai',
providerLabel: 'OpenAI',
});
@@ -1230,9 +1240,9 @@ describe('AIChat', () => {
renderChat();
const switchButton = await screen.findByRole('button', {
name: 'Use OpenRouter provider route',
name: 'Use OpenRouter route',
});
expect(switchButton).toHaveTextContent('Use OpenRouter');
expect(switchButton).toHaveTextContent('Use OpenRouter route');
fireEvent.click(switchButton);
@@ -1281,7 +1291,7 @@ describe('AIChat', () => {
renderChat();
await screen.findByRole('button', {
name: 'Use OpenRouter provider route',
name: 'Use OpenRouter route',
});
const textarea = screen.getByPlaceholderText(
@@ -4648,7 +4658,7 @@ describe('AIChat', () => {
]);
expect(
screen.queryByRole('status', { name: 'Assistant fallback route adopted' }),
screen.queryByRole('status', { name: 'Assistant model route switch adopted' }),
).not.toBeInTheDocument();
expect(mockChat.setModel).not.toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro');
expect(mockNotificationStore.success).not.toHaveBeenCalledWith(
@@ -429,6 +429,7 @@ describe('ChatMessages', () => {
it('forwards message-specific route recovery options for failed turns', () => {
const routeAlternative: ModelRouteRecoveryOption = {
id: 'openrouter:deepseek/deepseek-v4-pro',
kind: 'same-model-route',
label: 'DeepSeek: DeepSeek V4 Pro via OpenRouter',
provider: 'openrouter',
providerLabel: 'OpenRouter',
@@ -230,6 +230,7 @@ describe('MessageItem', () => {
onChangeModel={onChangeModel}
modelRouteAlternative={{
id: 'openrouter:deepseek/deepseek-v4-pro',
kind: 'same-model-route',
label: 'DeepSeek: DeepSeek V4 Pro via OpenRouter',
provider: 'openrouter',
providerLabel: 'OpenRouter',
@@ -243,9 +244,9 @@ describe('MessageItem', () => {
expect(alert.textContent).toContain('billing or quota reasons');
const retryViaOpenRouter = screen.getByRole('button', {
name: 'Retry via OpenRouter provider route',
name: 'Retry with OpenRouter route',
});
expect(retryViaOpenRouter).toHaveTextContent('Retry via OpenRouter');
expect(retryViaOpenRouter).toHaveTextContent('Retry with OpenRouter route');
fireEvent.click(retryViaOpenRouter);
expect(onUseModelRoute).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro', 'msg-1');
@@ -220,7 +220,7 @@ describe('Assistant transcript export', () => {
expect(toolIndex).toBeLessThan(answerIndex);
});
it('formats initially selected model route events distinctly from fallback switches', () => {
it('formats initially selected model route events distinctly from route switches', () => {
const transcript = formatAssistantTranscript({
messages: [
{
@@ -1331,7 +1331,7 @@ describe('useChat', () => {
dispose();
});
it('surfaces obsolete provider fallback workflow metadata without switching routes', async () => {
it('surfaces obsolete provider route-switch workflow metadata without switching routes', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() =>
useChat({ sessionId: 's', model: 'openrouter:openai/gpt-4o-mini' }),
@@ -1355,7 +1355,7 @@ describe('useChat', () => {
expect(assistant.workflowStatus).toEqual(
expect.objectContaining({
phase: 'provider_fallback_rejected',
message: 'Provider fallback metadata received; selected route unchanged.',
message: 'Provider route-switch metadata ignored; selected route unchanged.',
}),
);
expect(assistant.streamEvents).toEqual(
@@ -1364,7 +1364,7 @@ describe('useChat', () => {
type: 'workflow_status',
workflowStatus: expect.objectContaining({
phase: 'provider_fallback_rejected',
message: 'Provider fallback metadata received; selected route unchanged.',
message: 'Provider route-switch metadata ignored; selected route unchanged.',
}),
}),
]),
@@ -1454,7 +1454,7 @@ describe('useChat', () => {
expect(assistant.workflowStatus).toEqual(
expect.objectContaining({
phase: 'provider_fallback_rejected',
message: 'Provider fallback metadata received; selected route unchanged.',
message: 'Provider route-switch metadata ignored; selected route unchanged.',
}),
);
expect(assistant.streamEvents).not.toEqual(
@@ -986,7 +986,7 @@ export function useChat(options: UseChatOptions = {}) {
if (phase === 'provider_fallback') {
return {
phase: 'provider_fallback_rejected',
message: 'Provider fallback metadata received; selected route unchanged.',
message: 'Provider route-switch metadata ignored; selected route unchanged.',
startedAt: Date.now(),
};
}
@@ -434,8 +434,10 @@ const findProviderReadinessAlternative = (args: {
sortedCandidates[0];
if (!candidate) return null;
const candidateKey = normalizeComparableModelKey(candidate.model.id);
return {
id: candidate.model.id,
kind: candidateKey === selectedKey ? 'same-model-route' : 'alternate-model-route',
label: formatAIModelRouteLabel(candidate.model),
provider: candidate.provider,
providerLabel: getAIProviderDisplayName(candidate.provider),
@@ -2228,6 +2230,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
switchToModelRoute(alternative.id);
};
const providerReadinessAlternativeButtonLabel = () => {
const alternative = providerReadinessAlternative();
if (!alternative) return '';
return alternative.kind === 'same-model-route'
? `Use ${alternative.providerLabel} route`
: `Use ${alternative.providerLabel} model route`;
};
createEffect(() => {
const sessionId = chat.sessionId();
const storedModel = getStoredModel(sessionId);
@@ -4209,10 +4219,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
type="button"
onClick={switchToProviderReadinessAlternative}
class="inline-flex max-w-[11rem] items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
aria-label={`Use ${alternative().providerLabel} provider route`}
aria-label={providerReadinessAlternativeButtonLabel()}
title={alternative().label}
>
<span class="truncate">Use {alternative().providerLabel}</span>
<span class="truncate">{providerReadinessAlternativeButtonLabel()}</span>
</button>
)}
</Show>
@@ -185,6 +185,7 @@ export interface ChatMessage {
export interface ModelRouteRecoveryOption {
id: string;
kind: 'same-model-route' | 'alternate-model-route';
label: string;
provider: string;
providerLabel: string;