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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 07:50:52 -07:00
parent f04f01db1d
commit a65633d878
9 changed files with 305 additions and 29 deletions

View File

@@ -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.

View File

@@ -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 { export interface ChatStreamHandlers {
onThinking?: (data: string) => void; onThinking?: (data: string) => void;
onText?: (data: string) => void; onText?: (data: string) => void;
@@ -10256,7 +10263,7 @@ export interface ChatStreamHandlers {
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void; onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void; onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void;
onDone?: (data: { messageId: string; message?: ChatMessage }) => void; onDone?: (data: { messageId: string; message?: ChatMessage }) => void;
onError?: (data: string | ChatFailureInfo) => void; onError?: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void;
onConnectionStateChange?: (state: StreamConnectionState) => void; onConnectionStateChange?: (state: StreamConnectionState) => void;
} }
@@ -10273,6 +10280,7 @@ export function streamChatResponse(
const abortController = new AbortController(); const abortController = new AbortController();
let closedByUser = false; let closedByUser = false;
let terminated = false; let terminated = false;
let requestAccepted = false;
let receivedStreamEvent = false; let receivedStreamEvent = false;
const firstEventTimeoutMs = Math.max(1_000, options?.firstEventTimeoutMs ?? 60_000); const firstEventTimeoutMs = Math.max(1_000, options?.firstEventTimeoutMs ?? 60_000);
let firstEventTimer: ReturnType<typeof setTimeout> | null = null; let firstEventTimer: ReturnType<typeof setTimeout> | null = null;
@@ -10349,7 +10357,7 @@ export function streamChatResponse(
break; break;
case "error": case "error":
terminated = true; terminated = true;
handlers.onError?.(parseChatErrorPayload(rawData)); handlers.onError?.(parseChatErrorPayload(rawData), { requestAccepted: true, receivedStreamEvent: true });
break; break;
} }
}; };
@@ -10382,22 +10390,23 @@ export function streamChatResponse(
const parsed = JSON.parse(errorBody); const parsed = JSON.parse(errorBody);
errorMsg = parsed.error || errorMsg; errorMsg = parsed.error || errorMsg;
} catch { /* use default */ } } catch { /* use default */ }
handlers.onError?.(errorMsg); handlers.onError?.(errorMsg, { requestAccepted: false, receivedStreamEvent: false });
return; return;
} }
if (!res.body) { if (!res.body) {
handlers.onError?.("No response body"); handlers.onError?.("No response body", { requestAccepted: true, receivedStreamEvent: false });
return; return;
} }
requestAccepted = true;
handlers.onConnectionStateChange?.("connected"); handlers.onConnectionStateChange?.("connected");
firstEventTimer = setTimeout(() => { firstEventTimer = setTimeout(() => {
if (terminated || closedByUser || receivedStreamEvent) { if (terminated || closedByUser || receivedStreamEvent) {
return; return;
} }
terminated = true; 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(); abortController.abort();
}, firstEventTimeoutMs); }, firstEventTimeoutMs);
@@ -10471,13 +10480,13 @@ export function streamChatResponse(
// trailing event that should be dropped rather than surfaced as transport // trailing event that should be dropped rather than surfaced as transport
// failure. // failure.
if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) { if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) {
handlers.onError?.("Connection closed unexpectedly"); handlers.onError?.("Connection closed unexpectedly", { requestAccepted, receivedStreamEvent });
} }
clearFirstEventTimer(); clearFirstEventTimer();
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") { if (err instanceof DOMException && err.name === "AbortError") {
if (!closedByUser && !terminated) { if (!closedByUser && !terminated) {
handlers.onError?.("Connection aborted"); handlers.onError?.("Connection aborted", { requestAccepted, receivedStreamEvent });
} }
clearFirstEventTimer(); clearFirstEventTimer();
return; return;
@@ -10487,7 +10496,7 @@ export function streamChatResponse(
return; return;
} }
clearFirstEventTimer(); clearFirstEventTimer();
handlers.onError?.(err instanceof Error ? err.message : "Connection error"); handlers.onError?.(err instanceof Error ? err.message : "Connection error", { requestAccepted, receivedStreamEvent });
} }
})(); })();

View File

@@ -5,7 +5,7 @@ import { Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; 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 { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon"; 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 { function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = [], thinkingOutput = ""): ChatMessage {
return { return {
id: "streaming-assistant", id: "streaming-assistant",
@@ -445,7 +466,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId) void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId)
.then(({ messages: refreshed }) => { .then(({ messages: refreshed }) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
setMessages(sortMessages(refreshed)); setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
}) })
.catch((refreshError) => { .catch((refreshError) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
@@ -455,7 +476,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
}); });
} }
}, },
onError: (streamError) => { onError: (streamError, meta?: ChatStreamErrorMeta) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
const message = typeof streamError === "string" ? streamError : streamError.summary; const message = typeof streamError === "string" ? streamError : streamError.summary;
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond")); 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"); setComposerState("idle");
setStreamingThinking(""); setStreamingThinking("");
streamRef.current = null; 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, undefined,
@@ -585,6 +623,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
FNXC:TaskDetailPlannerChat 2026-07-01-09:34: 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. 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: 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. 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.
*/ */

View File

@@ -897,6 +897,77 @@ describe("TaskPlannerChatTab", () => {
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1); 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(
<TaskPlannerChatTab
task={makeTask("FN-7312")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
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 () => { it("shows API errors and re-enables the composer", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {

View File

@@ -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 () => { it("removes message on chat:message:deleted event", async () => {
mockFetchChatSessions.mockResolvedValueOnce({ mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],

View File

@@ -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"); 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] }); mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1")); const { result } = renderHook(() => useChatRooms("proj-1"));

View File

@@ -1,6 +1,6 @@
import type { ChatMessage } from "@fusion/core"; import type { ChatMessage } from "@fusion/core";
import type { Dispatch, RefObject, SetStateAction } from "react"; 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"; import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
/** /**
@@ -52,7 +52,7 @@ export interface CreateChatStreamHandlersOptions {
fallbackInfo?: FallbackInfo; fallbackInfo?: FallbackInfo;
}; };
}) => void; }) => 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 * 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. * 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; onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback: (data: FallbackInfo) => void; onFallback: (data: FallbackInfo) => void;
onDone: (data: { messageId: string; message?: ChatMessage }) => void; onDone: (data: { messageId: string; message?: ChatMessage }) => void;
onError: (data: string | ChatFailureInfo) => void; onError: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void;
} }
export interface CreateChatStreamHandlersResult { export interface CreateChatStreamHandlersResult {
@@ -214,9 +214,9 @@ export function createChatStreamHandlers(
}, },
}); });
}, },
onError: (data: string | ChatFailureInfo) => { onError: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => {
cancelFlushes(); cancelFlushes();
onError(data, tempUserMessageId); onError(data, tempUserMessageId, meta);
}, },
}; };

View File

@@ -11,6 +11,7 @@ import {
cancelChatResponse, cancelChatResponse,
type ChatFailureInfo, type ChatFailureInfo,
type ChatSessionListResponse, type ChatSessionListResponse,
type ChatStreamErrorMeta,
} from "../api"; } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; 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( export function useChat(
projectId?: string, projectId?: string,
addToast?: (msg: string, type?: "success" | "error" | "warning") => void, addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
@@ -1121,13 +1136,20 @@ export function useChat(
flushPendingMessage(); flushPendingMessage();
}, },
onError: (data, tempUserMessageId) => { onError: (data, tempUserMessageId, meta?: ChatStreamErrorMeta) => {
const failureInfo = normalizeFailureInfo(data); const failureInfo = normalizeFailureInfo(data);
const suspensionMessage = typeof data === "string" ? data : failureInfo.summary; const suspensionMessage = typeof data === "string" ? data : failureInfo.summary;
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage); 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) => { setMessages((prev) => {
const nextMessages = prev.filter((message) => message.id !== tempUserMessageId); const nextMessages = acceptedByServer
? prev
: prev.filter((message) => message.id !== tempUserMessageId);
if (shouldSuppressSuspensionError) { if (shouldSuppressSuspensionError) {
return nextMessages; return nextMessages;
} }
@@ -1164,6 +1186,17 @@ export function useChat(
} }
} else { } else {
addToast?.(failureInfo.summary, "error"); 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(); void refreshSessions();
} }
@@ -1386,16 +1419,7 @@ export function useChat(
// Reconcile optimistic local user messages against persisted SSE echoes. // Reconcile optimistic local user messages against persisted SSE echoes.
// The optimistic message uses a temp id and should be replaced instead of appended. // The optimistic message uses a temp id and should be replaced instead of appended.
if (message.role === "user") { if (message.role === "user") {
const optimisticIndex = prev.findIndex((candidate) => return reconcileOptimisticSentMessage(prev, message);
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 [...prev, message]; return [...prev, message];

View File

@@ -310,6 +310,9 @@ export function useChatRooms(
* Error contract: * Error contract:
* - Throws the original error when delivery did not happen (before `postChatRoomMessage` resolves); callers may restore composer text. * - 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. * - 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 sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => {
const activeRoomSnapshot = activeRoomRef.current; const activeRoomSnapshot = activeRoomRef.current;
@@ -394,7 +397,7 @@ export function useChatRooms(
timer.mark("hydrate"); timer.mark("hydrate");
} }
} catch { } catch {
if (activeRoomRef.current?.id === roomId) { if (!userMessageDelivered && activeRoomRef.current?.id === roomId) {
setMessages((previous) => { setMessages((previous) => {
const next = previous.filter((message) => message.id !== optimisticMessage.id); const next = previous.filter((message) => message.id !== optimisticMessage.id);
// Snapshot mirrors server `order: desc` shape. // Snapshot mirrors server `order: desc` shape.