fix: prevent SSE connection drops from silently truncating responses (#7831)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jh-block
2026-03-12 13:22:35 +01:00
committed by GitHub
parent cd12a01b30
commit f65530bce5
+23 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { AppEvents } from '../constants/events'; import { AppEvents } from '../constants/events';
import { ChatState } from '../types/chatState'; import { ChatState } from '../types/chatState';
import { toastError } from '../toasts';
import { import {
getSession, getSession,
@@ -206,7 +207,8 @@ async function streamFromResponse(
initialMessages: Message[], initialMessages: Message[],
dispatch: React.Dispatch<StreamAction>, dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void, onFinish: (error?: string) => void,
sessionId: string sessionId: string,
signal?: AbortController['signal']
): Promise<void> { ): Promise<void> {
let currentMessages = initialMessages; let currentMessages = initialMessages;
const reduceMotion = prefersReducedMotion(); const reduceMotion = prefersReducedMotion();
@@ -309,8 +311,22 @@ async function streamFromResponse(
} }
} }
// If we reach here, the stream ended without a Finish or Error event.
// This happens when the connection drops and retries are exhausted — the
// generator exits its loop without yielding a terminal event. We call
// onFinish() without an error to keep the conversation visible (passing
// an error would trigger a full-page error screen via sessionLoadError),
// then show a toast so the user knows the response may be incomplete.
// If the signal was aborted, the user intentionally stopped streaming,
// so we skip the toast.
flushBatchedUpdates(); flushBatchedUpdates();
onFinish(); onFinish();
if (!signal?.aborted) {
toastError({
title: 'Connection lost',
msg: 'The response may be incomplete. You can try sending your message again.',
});
}
} catch (error) { } catch (error) {
flushBatchedUpdates(); flushBatchedUpdates();
if (error instanceof Error && error.name !== 'AbortError') { if (error instanceof Error && error.name !== 'AbortError') {
@@ -593,9 +609,10 @@ export function useChatStream({
}, },
throwOnError: true, throwOnError: true,
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
}); });
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId); await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
} catch (error) { } catch (error) {
// AbortError is expected when user stops streaming // AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
@@ -634,9 +651,10 @@ export function useChatStream({
}, },
throwOnError: true, throwOnError: true,
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
}); });
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId); await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort // Silently handle abort
@@ -773,9 +791,10 @@ export function useChatStream({
}, },
throwOnError: true, throwOnError: true,
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
}); });
await streamFromResponse(stream, messagesForUI, dispatch, onFinish, targetSessionId); await streamFromResponse(stream, messagesForUI, dispatch, onFinish, targetSessionId, abortControllerRef.current.signal);
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });