From 206161b8ea88b6c4d2cb5f87d58f995affde0c6b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 20:48:54 +0100 Subject: [PATCH] Route Assistant sends around failed providers --- .../v6/internal/subsystems/ai-runtime.md | 13 ++-- .../AI/Chat/__tests__/AIChat.test.tsx | 60 +++++++++++++++++++ .../src/components/AI/Chat/index.tsx | 19 ++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4bf3eb3cb..c5b7e534f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -90,11 +90,14 @@ runtime cost control, and shared AI transport surfaces. assistant-authored output. Provider checking is a background diagnostic and must not delay the user's first useful chat turn; a confirmed selected-route provider error must keep typed text and focus without blocking normal chat - dispatch. If the operator sends while that warning is still visible, the - send path owns the attempt and any retryable provider failure is handled as - a normal failed turn with draft restoration. Same-model configured-provider - alternatives remain one-click route changes, not required preconditions for - sending. + dispatch. If a same-model configured-provider alternative is available when + the operator sends while that warning is still visible, the drawer must + promote that route before dispatching the turn instead of knowingly sending + through the broken provider again. When no configured alternative is + available, the send path owns the attempt and any retryable provider failure + is handled as a normal failed turn with draft restoration. Same-model + configured-provider alternatives remain one-click route changes as well as + send-time recovery choices, not required preconditions for typing or sending. Follow-up sends during an active Assistant response are chat-runtime queue state by default. The drawer must accept and echo the user's follow-up as a queued user turn without aborting or replacing the active model stream, must 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 a75fb419f..d82fe90fc 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -818,6 +818,66 @@ describe('AIChat', () => { ); }); + it('uses a configured-provider route automatically when sending after a selected provider failure', async () => { + mockAIAPI.getSettings.mockResolvedValue({ + model: 'deepseek:deepseek-v4-pro', + chat_model: '', + control_level: 'read_only', + autonomous_mode: false, + discovery_enabled: true, + configured_providers: ['deepseek', 'openrouter'], + }); + mockAIAPI.getModels.mockResolvedValue({ + models: [ + { + id: 'deepseek:deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + provider: 'deepseek', + notable: true, + }, + { + id: 'openrouter:deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'openrouter', + notable: true, + }, + ], + }); + mockAIAPI.testProvider.mockResolvedValueOnce({ + success: false, + message: 'Provider connection issue', + provider: 'deepseek', + model: 'deepseek:deepseek-v4-pro', + cause: 'provider_connection', + summary: 'Pulse could not maintain a healthy connection to this provider.', + recommendation: 'Check provider reachability.', + action: 'open_provider_settings', + }); + + renderChat(); + + await screen.findByRole('button', { + name: 'Use OpenRouter provider route', + }); + + const textarea = screen.getByPlaceholderText( + 'Ask about your infrastructure...', + ) as HTMLTextAreaElement; + fireEvent.input(textarea, { target: { value: 'summarize the cluster' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); + expect(mockChat.sendMessage).toHaveBeenCalledWith( + 'summarize the cluster', + undefined, + undefined, + ); + expect(mockChat.setModel.mock.invocationCallOrder[0]).toBeLessThan( + mockChat.sendMessage.mock.invocationCallOrder[0], + ); + expect(textarea.value).toBe(''); + }); + it('rechecks provider readiness from the drawer status banner', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'deepseek:deepseek-v4-pro', diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 172705ae5..4d4a23cc8 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -957,6 +957,23 @@ export const AIChat: Component = (props) => { switchToModelRoute(alternative.id); }; + const selectProviderReadinessAlternativeForSend = () => { + const readiness = providerReadiness(); + if (readiness.status !== 'error') return null; + + const currentProvider = selectedChatProvider().trim(); + const failedProvider = readiness.provider.trim(); + if (failedProvider && currentProvider && failedProvider !== currentProvider) { + return null; + } + + const alternative = providerReadinessAlternative(); + if (!alternative) return null; + + selectModel(alternative.id); + return alternative; + }; + createEffect(() => { const sessionId = chat.sessionId(); const storedModel = getStoredModel(sessionId); @@ -1538,6 +1555,8 @@ export const AIChat: Component = (props) => { Boolean(sendOptions.handoffResources?.length) || Boolean(sendOptions.handoffActions?.length) || Boolean(sendOptions.handoffMetadata); + selectProviderReadinessAlternativeForSend(); + const sendPromise = hasSendOptions ? chat.sendMessage(prompt, mentionsForAPI, findingId, sendOptions) : chat.sendMessage(prompt, mentionsForAPI, findingId);