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:
7
.changeset/fn-7368-chat-provider-error.md
Normal file
7
.changeset/fn-7368-chat-provider-error.md
Normal 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.
|
||||
@@ -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<typeof setTimeout> | 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 });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
const user = userEvent.setup();
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
|
||||
@@ -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" })],
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user