Make Assistant route recovery retry failed turns

This commit is contained in:
rcourtman
2026-06-05 17:32:15 +01:00
parent 7a05826ecd
commit a54d643b7a
7 changed files with 39 additions and 26 deletions
@@ -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
@@ -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;
@@ -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<MessageItemProps> = (props) => {
{(alternative) => (
<button
type="button"
onClick={() => props.onUseModelRoute?.(alternative().id)}
aria-label={`Use ${alternative().providerLabel} provider route`}
onClick={() =>
props.onUseModelRoute?.(alternative().id, props.message.id)
}
aria-label={`Retry via ${alternative().providerLabel} provider route`}
title={alternative().label}
class="inline-flex max-w-[11rem] 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"
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">Use {alternative().providerLabel}</span>
<span class="truncate">Retry via {alternative().providerLabel}</span>
</button>
)}
</Show>
@@ -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
</button>
<Show when={routeAlternative()}>
{(alternative) => (
<Show when={routeRecovery()}>
{(recovery) => (
<button
type="button"
data-testid="mock-use-model-route"
onClick={() => props.onUseModelRoute?.(alternative().id)}
onClick={() =>
props.onUseModelRoute?.(recovery().alternative.id, recovery().failedMessage.id)
}
>
Use {alternative().providerLabel}
Retry via {recovery().alternative.providerLabel}
</button>
)}
</Show>
@@ -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...'),
);
@@ -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 (
@@ -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);
@@ -900,8 +900,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
return modelRouteAlternativeFor(message.model || selectedChatModel());
};
const switchToModelRoute = (modelId: string) => {
const switchToModelRoute = (modelId: string, failedMessageId?: string) => {
selectModel(modelId);
if (failedMessageId) {
chat.retryMessage(failedMessageId);
}
focusComposer();
};