diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index e1d28df9b..87cd93f75 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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. diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 4428f52b9..83f4d3e03 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -360,6 +360,13 @@ export const MessageItem: Component = (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 = (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" > - Retry via {alternative().providerLabel} + {modelRouteRecoveryButtonLabel()} )} diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 106e026a9..e9ec447ce 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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`} )} @@ -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( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx index 5a35c35ee..64a5d8497 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx @@ -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', diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 5d1cfaf94..7d3b0de83 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.test.ts index 27466120b..22b53b455 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.test.ts @@ -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: [ { 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 5d4fa41e2..0c5ce917e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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( diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 5e447bd83..c9b4419ae 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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(), }; } diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 656f2d2ce..6e8c4e391 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -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 = (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 = (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} > - Use {alternative().providerLabel} + {providerReadinessAlternativeButtonLabel()} )} diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 5c448b7ff..87b89c777 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -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;