Compare commits

...
Author SHA1 Message Date
sonarly-bot 3709c69052 fix(ai-chat): fallback refetch when SSE events are missing
https://sonarly.com/issue/41815?type=bug

AI responses are generated server-side but the chat UI does not receive live stream events, so assistant messages only appear after a later manual refetch (next send, reload, or re-open).

Fix: Implemented a frontend fallback path so chat updates no longer depend exclusively on SSE delivery.

In `useAgentChat.ts`, after a successful `sendChatMessage` mutation, the code now dispatches the existing refetch event immediately (existing behavior) and also schedules additional delayed refetches (2s, 5s, 12s). This ensures that if GraphQL-SSE stream events are dropped (e.g., proxy buffering/connection issues), the assistant response is still pulled into the UI without requiring a manual user action (reload, reopen, next send).

This change stays in the correct layer (frontend state refresh logic), keeps existing SSE behavior intact, and adds graceful degradation instead of changing backend streaming semantics.

Authored by Sonarly by autonomous analysis (run 47708).
2026-06-01 07:40:35 +00:00
2 changed files with 54 additions and 2 deletions
@@ -33,6 +33,8 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const AGENT_CHAT_SSE_FALLBACK_REFETCH_DELAYS_MS = [2000, 5000, 12000];
export const useAgentChat = (
ensureThreadIdForSend: () => Promise<string | null>,
) => {
@@ -185,6 +187,12 @@ export const useAgentChat = (
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
AGENT_CHAT_SSE_FALLBACK_REFETCH_DELAYS_MS.forEach((delayInMs) => {
window.setTimeout(() => {
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
}, delayInMs);
});
setPendingThreadIdAfterFirstSend((pendingId) => {
if (isDefined(pendingId)) {
setCurrentAiChatThread(pendingId);
@@ -1,3 +1,4 @@
import { captureException, withScope } from '@sentry/react';
import { useEffect } from 'react';
import { readUIMessageStream, type UIMessageChunk } from 'ai';
@@ -26,6 +27,7 @@ import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/j
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const THROTTLE_MS = 100;
const MIN_SSE_ERROR_CAPTURE_INTERVAL_MS = 30000;
// readUIMessageStream requires initialization chunks (start, start-step,
// text-start) before content chunks. When reconnecting to a thread mid-stream,
@@ -93,6 +95,9 @@ type AgentChatEventPayload = {
};
};
const isExpectedSseSubscriptionError = (error: unknown) =>
error instanceof Error && error.name === 'AbortError';
export const useAgentChatSubscription = (threadId: string | null) => {
const store = useStore();
const sseClient = useAtomStateValue(sseClientState);
@@ -141,6 +146,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
let latestMessage: ExtendedUIMessage | null = null;
let writer: WritableStreamDefaultWriter<UIMessageChunk> | null = null;
let disposed = false;
let lastCapturedSseErrorAt = 0;
store.set(firstLiveSeqAtom, null);
@@ -260,6 +266,37 @@ export const useAgentChatSubscription = (threadId: string | null) => {
}
};
const captureSseSubscriptionError = (
error: unknown,
source: 'next' | 'error',
) => {
if (isExpectedSseSubscriptionError(error)) {
return;
}
const now = Date.now();
if (now - lastCapturedSseErrorAt < MIN_SSE_ERROR_CAPTURE_INTERVAL_MS) {
return;
}
lastCapturedSseErrorAt = now;
const normalizedError =
error instanceof Error
? error
: new Error('Unexpected AI chat SSE subscription error', {
cause: error,
});
withScope((scope) => {
scope.setTag('area', 'ai-chat');
scope.setTag('source', source);
scope.setExtra('threadId', threadId);
captureException(normalizedError);
});
};
const handleEvent = (event: AgentChatSubscriptionEvent) => {
switch (event.type) {
case 'stream-chunk': {
@@ -340,14 +377,21 @@ export const useAgentChatSubscription = (threadId: string | null) => {
},
{
next: (value: ExecutionResult<AgentChatEventPayload>) => {
if (isDefined(value.errors) && value.errors.length > 0) {
captureSseSubscriptionError(value.errors[0], 'next');
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
return;
}
if (isDefined(value.data?.onAgentChatEvent?.event)) {
handleEvent(
value.data.onAgentChatEvent.event as AgentChatSubscriptionEvent,
);
}
},
error: () => {
// graphql-sse handles reconnection automatically
error: (error) => {
captureSseSubscriptionError(error, 'error');
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
},
complete: () => {
if (!disposed) {