diff --git a/apps/client/src/features/ai-chat/utils/error-message.ts b/apps/client/src/features/ai-chat/utils/error-message.ts index eae82a5c..c6577a49 100644 --- a/apps/client/src/features/ai-chat/utils/error-message.ts +++ b/apps/client/src/features/ai-chat/utils/error-message.ts @@ -85,8 +85,12 @@ function classifyProviderError(msg: string): ErrorCategory | null { // Connection dropped / provider unreachable. ECONNRESET is the production case: // the LLM socket was reset mid-stream. "terminated" is scoped to a connection/ // stream context so it does not match benign "... was terminated" messages. + // The browser's own fetch-failure messages also land here because they mean the + // SSE stream to /api/ai-chat/stream dropped mid-answer (e.g. a reverse proxy cut + // it): WebKit/Safari says "Load failed", Chrome "Failed to fetch", Firefox + // "NetworkError when attempting to fetch resource". if ( - /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test( + /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|failed to fetch|load failed|networkerror|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test( head, ) ) { diff --git a/apps/server/src/core/ai-chat/ai-chat.controller.ts b/apps/server/src/core/ai-chat/ai-chat.controller.ts index 02a98971..0870969e 100644 --- a/apps/server/src/core/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/core/ai-chat/ai-chat.controller.ts @@ -161,7 +161,16 @@ export class AiChatController { // cannot simply remove it once `stream()` returns). const controller = new AbortController(); const onClose = (): void => { - if (!res.raw.writableEnded) controller.abort(); + // A genuine disconnect leaves the response unfinished (unlike a normal + // completion, which also fires `close`). Such a drop — e.g. a reverse + // proxy cutting the SSE mid-answer — is otherwise invisible server-side, + // so log it here before aborting the agent loop. + if (!res.raw.writableEnded) { + this.logger.warn( + 'AI chat stream: client disconnected before completion; aborting turn', + ); + controller.abort(); + } }; req.raw.once('close', onClose); res.raw.once('finish', () => req.raw.off('close', onClose));