Route Assistant sends around failed providers

This commit is contained in:
rcourtman
2026-06-05 20:48:54 +01:00
parent 5e4b720d5f
commit 206161b8ea
3 changed files with 87 additions and 5 deletions
@@ -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
@@ -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',
@@ -957,6 +957,23 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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);