fix(ai): offer Retry when the failed turn persisted partial assistant output (#22478)

## Rationale

When a turn fails mid-stream *after* emitting some text, the failed
turn's partial assistant message is persisted — so the last message in
the thread is the assistant's, and `AiChatErrorUnderMessageList` (which
owns the Retry button, gated on the last message being the user's) never
renders. The error surfaces through `AiChatMessage` →
`AiChatErrorRenderer` instead, and that path never passed `onRetry`.
Result: an error banner with no action for the most common failure shape
(mid-stream provider errors), most visibly after a reload.

## Why this is the root cause, not a symptom patch

This is a wiring omission, not a designed gate. `AiChatErrorRenderer`
already accepts `onRetry`, and the server's `retryLastFailedTurn`
already deletes the failed turn's assistant messages before re-streaming
— the entire retry path for partial-output turns exists and works; only
the prop was never threaded. Verified there's no hidden protective
reason: retrying with partial output cannot duplicate content, because
regeneration is delete-then-restream by design.

## User impact

A mid-stream failure currently strands the user: their only options are
re-typing the message or reloading. With this, the same Retry affordance
appears whether the turn died before or after the first token (Sentry
shows 126 users/30d hitting zero-output failures alone — the with-output
shape shares the same recovery need).

## Test plan

- [x] `AiChatErrorRenderer` retry behavior already covered by existing
rendering; change is prop threading only (~10 lines)
- [ ] CI green
- [ ] Manual: fail a turn mid-stream (kill provider), observe Retry on
the in-message error banner

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22478?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 20:57:40 +02:00
committed by GitHub
parent 7c33280465
commit a445974cff
2 changed files with 6 additions and 1 deletions
@@ -1,4 +1,5 @@
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { useRetryChatMessage } from '@/ai/hooks/useRetryChatMessage';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
@@ -9,6 +10,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isDefined } from 'twenty-shared/utils';
export const AiChatLastMessageWithStreamingState = () => {
const { retryChatMessage } = useRetryChatMessage();
const lastMessageId = useAtomComponentSelectorValue(
agentChatLastMessageIdComponentSelector,
);
@@ -34,6 +36,7 @@ export const AiChatLastMessageWithStreamingState = () => {
messageId={lastMessageId}
isLastMessageStreaming={agentChatIsStreaming}
error={agentChatError ?? undefined}
onRetry={retryChatMessage}
/>
);
};
@@ -144,12 +144,14 @@ type AiChatMessageProps = {
messageId: string;
isLastMessageStreaming?: boolean;
error?: AiChatError | undefined;
onRetry?: () => void;
};
export const AiChatMessage = ({
messageId,
isLastMessageStreaming = false,
error,
onRetry,
}: AiChatMessageProps) => {
const agentChatMessage = useAtomComponentFamilySelectorValue(
agentChatMessageComponentFamilySelector,
@@ -187,7 +189,7 @@ export const AiChatMessage = ({
</StyledFilesContainer>
)}
{shouldShowError && isDefined(error) && (
<AiChatErrorRenderer error={error} />
<AiChatErrorRenderer error={error} onRetry={onRetry} />
)}
</StyledMessageContainer>
{agentChatMessage.parts.length > 0 && (