From a65633d878ea62aa17b586efcdbba429e79b26c6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 07:50:52 -0700 Subject: [PATCH] FN-7368: keep accepted chat sends visible after provider errors Preserve sent chat turns when provider failures arrive after the server accepts a stream. - Track whether chat stream errors happen before or after server acceptance. - Reconcile persisted user-message echoes with optimistic bubbles across global chat and planner chat. - Preserve delivered room-chat messages when reply generation or recovery refresh fails. - Cover accepted-error and pre-acceptance rollback behavior with focused dashboard tests. Files changed: .changeset/fn-7368-chat-provider-error.md | 7 ++ packages/dashboard/app/api/legacy.ts | 25 ++++-- .../app/components/TaskPlannerChatTab.tsx | 47 ++++++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 71 ++++++++++++++++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 96 ++++++++++++++++++++++ .../app/hooks/__tests__/useChatRooms.test.ts | 25 ++++++ .../app/hooks/createChatStreamHandlers.ts | 10 +-- packages/dashboard/app/hooks/useChat.ts | 48 ++++++++--- packages/dashboard/app/hooks/useChatRooms.ts | 5 +- 9 files changed, 305 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-7368 Fusion-Task-Lineage: b7935c3f-3604-4ed5-b32e-a6ead536a991 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7368-chat-provider-error.md | 7 ++ packages/dashboard/app/api/legacy.ts | 25 +++-- .../app/components/TaskPlannerChatTab.tsx | 47 ++++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 71 ++++++++++++++ .../app/hooks/__tests__/useChat.test.ts | 96 +++++++++++++++++++ .../app/hooks/__tests__/useChatRooms.test.ts | 25 +++++ .../app/hooks/createChatStreamHandlers.ts | 10 +- packages/dashboard/app/hooks/useChat.ts | 48 +++++++--- packages/dashboard/app/hooks/useChatRooms.ts | 5 +- 9 files changed, 305 insertions(+), 29 deletions(-) create mode 100644 .changeset/fn-7368-chat-provider-error.md diff --git a/.changeset/fn-7368-chat-provider-error.md b/.changeset/fn-7368-chat-provider-error.md new file mode 100644 index 0000000000..e835ac614a --- /dev/null +++ b/.changeset/fn-7368-chat-provider-error.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep sent chat messages visible when a provider error interrupts the reply. +category: fix +dev: Reconciles accepted optimistic chat sends with persisted transcripts across global, planner, and room chats. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 9028a2f1b6..36c80cbda6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10249,6 +10249,13 @@ function parseChatErrorPayload(rawData: string): string | ChatFailureInfo { } } +export interface ChatStreamErrorMeta { + /** True once the POST stream was accepted and the server started an SSE response. */ + requestAccepted: boolean; + /** True when the error came from an SSE event rather than the initial HTTP response. */ + receivedStreamEvent: boolean; +} + export interface ChatStreamHandlers { onThinking?: (data: string) => void; onText?: (data: string) => void; @@ -10256,7 +10263,7 @@ export interface ChatStreamHandlers { onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void; onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void; onDone?: (data: { messageId: string; message?: ChatMessage }) => void; - onError?: (data: string | ChatFailureInfo) => void; + onError?: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void; onConnectionStateChange?: (state: StreamConnectionState) => void; } @@ -10273,6 +10280,7 @@ export function streamChatResponse( const abortController = new AbortController(); let closedByUser = false; let terminated = false; + let requestAccepted = false; let receivedStreamEvent = false; const firstEventTimeoutMs = Math.max(1_000, options?.firstEventTimeoutMs ?? 60_000); let firstEventTimer: ReturnType | null = null; @@ -10349,7 +10357,7 @@ export function streamChatResponse( break; case "error": terminated = true; - handlers.onError?.(parseChatErrorPayload(rawData)); + handlers.onError?.(parseChatErrorPayload(rawData), { requestAccepted: true, receivedStreamEvent: true }); break; } }; @@ -10382,22 +10390,23 @@ export function streamChatResponse( const parsed = JSON.parse(errorBody); errorMsg = parsed.error || errorMsg; } catch { /* use default */ } - handlers.onError?.(errorMsg); + handlers.onError?.(errorMsg, { requestAccepted: false, receivedStreamEvent: false }); return; } if (!res.body) { - handlers.onError?.("No response body"); + handlers.onError?.("No response body", { requestAccepted: true, receivedStreamEvent: false }); return; } + requestAccepted = true; handlers.onConnectionStateChange?.("connected"); firstEventTimer = setTimeout(() => { if (terminated || closedByUser || receivedStreamEvent) { return; } terminated = true; - handlers.onError?.("Timed out waiting for first response event"); + handlers.onError?.("Timed out waiting for first response event", { requestAccepted: true, receivedStreamEvent: false }); abortController.abort(); }, firstEventTimeoutMs); @@ -10471,13 +10480,13 @@ export function streamChatResponse( // trailing event that should be dropped rather than surfaced as transport // failure. if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) { - handlers.onError?.("Connection closed unexpectedly"); + handlers.onError?.("Connection closed unexpectedly", { requestAccepted, receivedStreamEvent }); } clearFirstEventTimer(); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { if (!closedByUser && !terminated) { - handlers.onError?.("Connection aborted"); + handlers.onError?.("Connection aborted", { requestAccepted, receivedStreamEvent }); } clearFirstEventTimer(); return; @@ -10487,7 +10496,7 @@ export function streamChatResponse( return; } clearFirstEventTimer(); - handlers.onError?.(err instanceof Error ? err.message : "Connection error"); + handlers.onError?.(err instanceof Error ? err.message : "Connection error", { requestAccepted, receivedStreamEvent }); } })(); diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index ff07f1480e..c4403c7e07 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -5,7 +5,7 @@ import { Loader2, Maximize2, Minimize2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ToastType } from "../hooks/useToast"; import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; -import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse } from "../api"; +import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatStreamErrorMeta } from "../api"; import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ProviderIcon } from "./ProviderIcon"; @@ -101,6 +101,27 @@ function makeOptimisticUserMessage(sessionId: string, content: string): ChatMess }; } +function mergePlannerTranscriptWithOptimistic(current: ChatMessage[], refreshed: ChatMessage[]): ChatMessage[] { + let next = current.filter((message) => message.id !== "streaming-assistant"); + for (const persisted of sortMessages(refreshed)) { + if (next.some((message) => message.id === persisted.id)) continue; + if (persisted.role === "user") { + const optimisticIndex = next.findIndex((candidate) => + candidate.role === "user" + && candidate.id.startsWith("optimistic-") + && candidate.sessionId === persisted.sessionId + && candidate.content.trim() === persisted.content.trim(), + ); + if (optimisticIndex >= 0) { + next = next.map((candidate, index) => index === optimisticIndex ? persisted : candidate); + continue; + } + } + next = [...next, persisted]; + } + return sortMessages(next); +} + function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = [], thinkingOutput = ""): ChatMessage { return { id: "streaming-assistant", @@ -445,7 +466,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId) .then(({ messages: refreshed }) => { if (!isCurrentStreamRequest()) return; - setMessages(sortMessages(refreshed)); + setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed)); }) .catch((refreshError) => { if (!isCurrentStreamRequest()) return; @@ -455,7 +476,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }); } }, - onError: (streamError) => { + onError: (streamError, meta?: ChatStreamErrorMeta) => { if (!isCurrentStreamRequest()) return; const message = typeof streamError === "string" ? streamError : streamError.summary; setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond")); @@ -463,6 +484,23 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, setComposerState("idle"); setStreamingThinking(""); streamRef.current = null; + setMessages((current) => { + const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant"); + if (meta?.requestAccepted === false) { + return withoutStreaming.filter((candidate) => !(candidate.role === "user" && candidate.id.startsWith("optimistic-") && candidate.content.trim() === content)); + } + return withoutStreaming; + }); + if (meta?.requestAccepted !== false) { + void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId) + .then(({ messages: refreshed }) => { + if (!isCurrentStreamRequest()) return; + setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed)); + }) + .catch(() => { + // Keep the accepted optimistic user turn visible; a later refresh/SSE will reconcile the persisted id. + }); + } }, }, undefined, @@ -585,6 +623,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, FNXC:TaskDetailPlannerChat 2026-07-01-09:34: Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity. + FNXC:TaskDetailPlannerChat 2026-07-01-00:00: + Provider failures after planner-chat stream acceptance must keep the user's visible turn because the server may have persisted it and included it in model context. Reconcile accepted optimistic rows with refreshed history, but roll back only explicit pre-acceptance failures. + FNXC:TaskDetailPlannerChat 2026-06-30-23:58: The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation. */ diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 295dc113c1..d4f1d6742d 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -897,6 +897,77 @@ describe("TaskPlannerChatTab", () => { expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1); }); + it("keeps first planner message visible after accepted provider error and reconciles persisted history", async () => { + const user = userEvent.setup(); + mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null }); + mockFetchChatMessages.mockResolvedValueOnce({ + messages: [{ id: "planner-user-1", sessionId: "chat-planner", role: "user", content: "hello after 429", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:00.000Z" }], + }); + let errorHandler: any; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.type(screen.getByLabelText("Message planner chat"), "hello after 429"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("hello after 429")).toBeInTheDocument(); + act(() => errorHandler?.({ summary: "Planner provider rate limit" }, { requestAccepted: true, receivedStreamEvent: true })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Planner provider rate limit"); + await waitFor(() => expect(screen.getAllByText("hello after 429")).toHaveLength(1)); + expect(mockFetchChatMessages).toHaveBeenCalledWith("chat-planner", { order: "asc" }, undefined); + }); + + it("rolls back planner optimistic message for pre-acceptance failures", async () => { + const user = userEvent.setup(); + let errorHandler: any; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.type(screen.getByLabelText("Message planner chat"), "blocked before persist"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("blocked before persist")).toBeInTheDocument(); + act(() => errorHandler?.("Request failed: 429", { requestAccepted: false, receivedStreamEvent: false })); + + await waitFor(() => expect(screen.queryByText("blocked before persist")).not.toBeInTheDocument()); + expect(screen.getByRole("alert")).toHaveTextContent("Request failed: 429"); + }); + + it("ignores stale planner provider errors after the task scope changes", async () => { + const user = userEvent.setup(); + let oldHandlers: any; + mockStreamChatResponse.mockImplementationOnce((_sessionId, _content, handlers) => { + oldHandlers = handlers; + return { close: vi.fn(), isConnected: () => true }; + }); + const { rerender } = renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.type(screen.getByLabelText("Message planner chat"), "old task message"); + await user.click(screen.getByRole("button", { name: "Send" })); + + rerender( + , + ); + await screen.findByTestId("task-planner-chat-empty"); + act(() => oldHandlers.onError("Stale provider error", { requestAccepted: true, receivedStreamEvent: true })); + + expect(screen.queryByText("Stale provider error")).not.toBeInTheDocument(); + }); + it("shows API errors and re-enables the composer", async () => { const user = userEvent.setup(); mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 154eba8403..772ca503c2 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -3812,6 +3812,102 @@ describe("useChat", () => { }); }); + it("keeps accepted sent message visible after provider error and reconciles persisted echo", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ + messages: [makeMessage({ id: "msg-user-001", sessionId: "session-001", role: "user", content: "hello after 429" })], + }); + + let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + act(() => { + result.current.sendMessage("hello after 429"); + }); + await waitFor(() => expect(result.current.messages.some((message) => message.content === "hello after 429")).toBe(true)); + + act(() => { + errorHandler?.({ summary: "Provider rate limit", code: "rate_limit" }, { requestAccepted: true, receivedStreamEvent: true }); + }); + + await waitFor(() => { + const matching = result.current.messages.filter((message) => message.role === "user" && message.content === "hello after 429"); + expect(matching).toHaveLength(1); + expect(matching[0]?.id).toBe("msg-user-001"); + }); + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.some((message) => message.role === "assistant" && message.failureInfo?.summary === "Provider rate limit")).toBe(true); + }); + + it("does not keep optimistic sent message for pre-acceptance HTTP failures", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + act(() => result.current.sendMessage("blocked before persist")); + act(() => errorHandler?.("Request failed: 429", { requestAccepted: false, receivedStreamEvent: false })); + + await waitFor(() => { + expect(result.current.messages.some((message) => message.content === "blocked before persist" && message.role === "user")).toBe(false); + expect(result.current.isStreaming).toBe(false); + }); + }); + + it("flushes queued direct message after accepted provider error becomes idle", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValue({ messages: [] }); + let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + errorHandler = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + act(() => { + result.current.sendMessage("first accepted"); + result.current.sendMessage("second queued"); + }); + expect(result.current.pendingMessages).toEqual(["second queued"]); + + act(() => errorHandler?.("Provider failed", { requestAccepted: true, receivedStreamEvent: true })); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(2); + expect(mockStreamChatResponse).toHaveBeenLastCalledWith("session-001", "second queued", expect.any(Object), undefined, "proj-123"); + }); + }); + it("removes message on chat:message:deleted event", async () => { mockFetchChatSessions.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts index 9fe07a3238..cf212ffeee 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts @@ -476,6 +476,31 @@ describe("useChatRooms", () => { ); }); + it("keeps delivered optimistic room message visible when reply and recovery refresh fail", async () => { + const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); + mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); + const { result } = renderHook(() => useChatRooms("proj-1")); + await waitFor(() => expect(result.current.rooms.length).toBe(1)); + + mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] }); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] }); + act(() => result.current.selectRoom("room-1")); + await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1")); + + mockPostChatRoomMessage.mockResolvedValueOnce({ message: roomMessage("msg-user", "room-1", "hello after 429") }); + mockFetchChatRoomMessages + .mockRejectedValueOnce(new Error("provider reply failed")) + .mockRejectedValueOnce(new Error("recovery failed")); + + await act(async () => { + await expect(result.current.sendRoomMessage("hello after 429")).rejects.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError); + }); + + const matchingMessages = result.current.messages.filter((message) => message.role === "user" && message.content === "hello after 429"); + expect(matchingMessages).toHaveLength(1); + expect(matchingMessages[0]?.id).toBe("msg-user"); + }); + it("classifies post rejection as delivered when recovery transcript includes persisted user message", async () => { const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); const { result } = renderHook(() => useChatRooms("proj-1")); diff --git a/packages/dashboard/app/hooks/createChatStreamHandlers.ts b/packages/dashboard/app/hooks/createChatStreamHandlers.ts index 9449fce789..778bc7b720 100644 --- a/packages/dashboard/app/hooks/createChatStreamHandlers.ts +++ b/packages/dashboard/app/hooks/createChatStreamHandlers.ts @@ -1,6 +1,6 @@ import type { ChatMessage } from "@fusion/core"; import type { Dispatch, RefObject, SetStateAction } from "react"; -import type { ChatFailureInfo } from "../api"; +import type { ChatFailureInfo, ChatStreamErrorMeta } from "../api"; import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes"; /** @@ -52,7 +52,7 @@ export interface CreateChatStreamHandlersOptions { fallbackInfo?: FallbackInfo; }; }) => void; - onError: (data: string | ChatFailureInfo, tempUserMessageId: string) => void; + onError: (data: string | ChatFailureInfo, tempUserMessageId: string, meta?: ChatStreamErrorMeta) => void; /** * Fallback-model side effect for the parent (e.g. updating the session list * or the active session's model fields). The factory still emits the toast. @@ -67,7 +67,7 @@ export interface ChatStreamHandlers { onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void; onFallback: (data: FallbackInfo) => void; onDone: (data: { messageId: string; message?: ChatMessage }) => void; - onError: (data: string | ChatFailureInfo) => void; + onError: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void; } export interface CreateChatStreamHandlersResult { @@ -214,9 +214,9 @@ export function createChatStreamHandlers( }, }); }, - onError: (data: string | ChatFailureInfo) => { + onError: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => { cancelFlushes(); - onError(data, tempUserMessageId); + onError(data, tempUserMessageId, meta); }, }; diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 9a807d63de..80b2ee90af 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -11,6 +11,7 @@ import { cancelChatResponse, type ChatFailureInfo, type ChatSessionListResponse, + type ChatStreamErrorMeta, } from "../api"; import { subscribeSse } from "../sse-bus"; import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; @@ -259,6 +260,20 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo { }; } +function reconcileOptimisticSentMessage(previous: ChatMessageInfo[], persisted: ChatMessageInfo): ChatMessageInfo[] { + if (previous.some((message) => message.id === persisted.id)) return previous; + const optimisticIndex = previous.findIndex((candidate) => + candidate.role === "user" + && candidate.id.startsWith("temp-") + && candidate.sessionId === persisted.sessionId + && candidate.content.trim() === persisted.content.trim(), + ); + if (optimisticIndex < 0) return [...previous, persisted]; + const next = [...previous]; + next[optimisticIndex] = persisted; + return next; +} + export function useChat( projectId?: string, addToast?: (msg: string, type?: "success" | "error" | "warning") => void, @@ -1121,13 +1136,20 @@ export function useChat( flushPendingMessage(); }, - onError: (data, tempUserMessageId) => { + onError: (data, tempUserMessageId, meta?: ChatStreamErrorMeta) => { const failureInfo = normalizeFailureInfo(data); const suspensionMessage = typeof data === "string" ? data : failureInfo.summary; const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage); + const acceptedByServer = meta?.requestAccepted === true; + /* + FNXC:ChatReliability 2026-07-01-00:00: + Provider errors can arrive after ChatManager has already persisted and sent the user's turn to the model context. Keep the visible user bubble for accepted streams and reconcile it with the persisted transcript instead of rolling it back like a pre-delivery HTTP validation failure. + */ setMessages((prev) => { - const nextMessages = prev.filter((message) => message.id !== tempUserMessageId); + const nextMessages = acceptedByServer + ? prev + : prev.filter((message) => message.id !== tempUserMessageId); if (shouldSuppressSuspensionError) { return nextMessages; } @@ -1164,6 +1186,17 @@ export function useChat( } } else { addToast?.(failureInfo.summary, "error"); + if (acceptedByServer) { + void fetchChatMessages(activeSession.id, { limit: 50, order: "desc" }, projectId) + .then((data) => { + if (activeSessionRef.current?.id !== activeSession.id) return; + const refreshed = data.messages.slice().reverse().map(mapChatMessageToInfo); + setMessages((current) => refreshed.reduce(reconcileOptimisticSentMessage, current)); + }) + .catch(() => { + // The optimistic accepted user bubble is already visible; the next SSE/refresh will reconcile the server id. + }); + } void refreshSessions(); } @@ -1386,16 +1419,7 @@ export function useChat( // Reconcile optimistic local user messages against persisted SSE echoes. // The optimistic message uses a temp id and should be replaced instead of appended. if (message.role === "user") { - const optimisticIndex = prev.findIndex((candidate) => - candidate.role === "user" - && candidate.id.startsWith("temp-") - && candidate.content.trim() === message.content.trim(), - ); - if (optimisticIndex >= 0) { - const next = [...prev]; - next[optimisticIndex] = message; - return next; - } + return reconcileOptimisticSentMessage(prev, message); } return [...prev, message]; diff --git a/packages/dashboard/app/hooks/useChatRooms.ts b/packages/dashboard/app/hooks/useChatRooms.ts index 5ecc8c2987..d2acf39dff 100644 --- a/packages/dashboard/app/hooks/useChatRooms.ts +++ b/packages/dashboard/app/hooks/useChatRooms.ts @@ -310,6 +310,9 @@ export function useChatRooms( * Error contract: * - Throws the original error when delivery did not happen (before `postChatRoomMessage` resolves); callers may restore composer text. * - Throws `RoomMessageDeliveredButReplyFailedError` when delivery succeeded but a post-send step failed; callers must keep composer cleared. + * + * FNXC:RoomChatReliability 2026-07-01-00:00: + * Responder/provider failures can occur after the room user message is persisted. Keep the optimistic or recovered user row visible for delivered sends even when the reply-generation or refresh step fails, because that turn is already part of the room transcript context. */ const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => { const activeRoomSnapshot = activeRoomRef.current; @@ -394,7 +397,7 @@ export function useChatRooms( timer.mark("hydrate"); } } catch { - if (activeRoomRef.current?.id === roomId) { + if (!userMessageDelivered && activeRoomRef.current?.id === roomId) { setMessages((previous) => { const next = previous.filter((message) => message.id !== optimisticMessage.id); // Snapshot mirrors server `order: desc` shape.