mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
Make Assistant route recovery retry failed turns
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user