From a445974cff8ae92720b5065dfe88bef53ec32376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 2 Jul 2026 20:57:40 +0200 Subject: [PATCH] fix(ai): offer Retry when the failed turn persisted partial assistant output (#22478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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)_ Review in cubic --- .../ai/components/AiChatLastMessageWithStreamingState.tsx | 3 +++ .../twenty-front/src/modules/ai/components/AiChatMessage.tsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/ai/components/AiChatLastMessageWithStreamingState.tsx b/packages/twenty-front/src/modules/ai/components/AiChatLastMessageWithStreamingState.tsx index bd89b886f1..bc5a3d950b 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatLastMessageWithStreamingState.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatLastMessageWithStreamingState.tsx @@ -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} /> ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AiChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AiChatMessage.tsx index 42599300a8..1ed3277982 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatMessage.tsx @@ -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 = ({ )} {shouldShowError && isDefined(error) && ( - + )} {agentChatMessage.parts.length > 0 && (