diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 0abba367f..18f80d778 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -122,9 +122,9 @@ runtime cost control, and shared AI transport surfaces. provider route to a configured gateway or alternate model without losing the draft or creating a parallel picker. When the same model is available through another configured provider route, the failed turn must offer that route as a - direct one-click action before falling back to the general model selector. - Retry remains available, but it must not be the only visible action when a - failed Assistant turn is shown. + direct one-click route-and-retry action before falling back to the general + model selector. Retry remains available, but it must not be the only visible + action when a failed Assistant turn is shown. Restored Assistant sessions must hydrate saved assistant content and persisted tool calls into the same transcript event shape used by live streams so switching sessions does not hide prior tool evidence or collapse diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index 93934e84f..5bae36d0f 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -22,7 +22,7 @@ interface ChatMessagesProps { onRetry?: (messageId: string) => void; onChangeModel?: () => void; getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; // Dashboard props recentSessions?: ChatSession[]; onLoadSession?: (sessionId: string) => void; diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 2f7cb0669..86c266f88 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -37,7 +37,7 @@ interface MessageItemProps { onRetry?: (messageId: string) => void; onChangeModel?: () => void; modelRouteAlternative?: ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; } const markdownClass = @@ -298,13 +298,15 @@ export const MessageItem: Component = (props) => { {(alternative) => ( )} 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 5599a4ed5..e0a2347cb 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -21,7 +21,7 @@ const { messages: ChatMessage[]; onChangeModel?: () => void; getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; }> = []; const mockChat = { messages: vi.fn((): ChatMessage[] => []), @@ -32,6 +32,7 @@ const { queuedFollowUps: vi.fn((): QueuedFollowUp[] => []), queuedFollowUpCount: vi.fn(() => 0), sendMessage: vi.fn().mockResolvedValue(true), + retryMessage: vi.fn(), stop: vi.fn(), cancelQueuedFollowUp: vi.fn(), takeQueuedFollowUp: vi.fn((): QueuedFollowUp | undefined => undefined), @@ -193,11 +194,12 @@ vi.mock('../ChatMessages', () => ({ emptyState?: { title: string; subtitle?: string }; onChangeModel?: () => void; getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; }) => { - const routeAlternative = () => { + const routeRecovery = () => { const failedMessage = props.messages.find((message) => message.error); - return failedMessage ? props.getModelRouteAlternative?.(failedMessage) : null; + const alternative = failedMessage ? props.getModelRouteAlternative?.(failedMessage) : null; + return failedMessage && alternative ? { alternative, failedMessage } : null; }; mockChatMessagesProps.push(props); return ( @@ -215,14 +217,16 @@ vi.mock('../ChatMessages', () => ({ > Change model - - {(alternative) => ( + + {(recovery) => ( )} @@ -433,7 +437,7 @@ describe('AIChat', () => { }); }); - it('switches a failed turn to an equivalent configured-provider route', async () => { + it('switches a failed turn to an equivalent configured-provider route and retries it', async () => { mockChat.model.mockReturnValue('deepseek:deepseek-v4-pro'); mockChat.messages.mockReturnValue([ { @@ -476,6 +480,7 @@ describe('AIChat', () => { fireEvent.click(await screen.findByTestId('mock-use-model-route')); expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); + expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-1'); expect(document.activeElement).toBe( screen.getByPlaceholderText('Ask about your infrastructure...'), ); 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 1371996a8..a3e469b44 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx @@ -20,7 +20,7 @@ let capturedMessageItemProps: Array<{ onSkipQuestion: (questionId: string) => void; onChangeModel?: () => void; modelRouteAlternative?: ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; }> = []; vi.mock('../MessageItem', () => ({ @@ -35,7 +35,7 @@ vi.mock('../MessageItem', () => ({ onSkipQuestion: (questionId: string) => void; onChangeModel?: () => void; modelRouteAlternative?: ModelRouteRecoveryOption | null; - onUseModelRoute?: (modelId: string) => void; + onUseModelRoute?: (modelId: string, messageId?: string) => void; }) => { capturedMessageItemProps.push(props); return ( 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 e7b6cf8f1..f87789723 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -172,12 +172,15 @@ describe('MessageItem', () => { expect(alert).toBeInTheDocument(); expect(alert.textContent).toContain('billing or quota reasons'); - const useOpenRouter = screen.getByRole('button', { - name: 'Use OpenRouter provider route', + const retryViaOpenRouter = screen.getByRole('button', { + name: 'Retry via OpenRouter provider route', }); - expect(useOpenRouter).toHaveTextContent('Use OpenRouter'); - fireEvent.click(useOpenRouter); - expect(onUseModelRoute).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); + expect(retryViaOpenRouter).toHaveTextContent('Retry via OpenRouter'); + fireEvent.click(retryViaOpenRouter); + expect(onUseModelRoute).toHaveBeenCalledWith( + 'openrouter:deepseek/deepseek-v4-pro', + 'msg-1', + ); const changeModel = screen.getByRole('button', { name: /change model/i }); fireEvent.click(changeModel); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 526139bdc..8cf618190 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -900,8 +900,11 @@ export const AIChat: Component = (props) => { return modelRouteAlternativeFor(message.model || selectedChatModel()); }; - const switchToModelRoute = (modelId: string) => { + const switchToModelRoute = (modelId: string, failedMessageId?: string) => { selectModel(modelId); + if (failedMessageId) { + chat.retryMessage(failedMessageId); + } focusComposer(); };