Merge commit '60a636efd8e6002a69bae78105fb3ed37de7de46'

This commit is contained in:
gsxdsm
2026-05-20 16:20:26 -07:00
4 changed files with 141 additions and 17 deletions

View File

@@ -25,7 +25,7 @@ import {
TriangleAlert,
} from "lucide-react";
import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat";
import { useChatRooms } from "../hooks/useChatRooms";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
import { useChatUnread } from "../hooks/useChatUnread";
import { useViewportMode } from "./Header";
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
@@ -1718,6 +1718,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
try {
await rooms.sendRoomMessage(trimmed, { files: pendingAttachments.map((attachment) => attachment.file) });
} catch (error) {
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
const message = error.message.trim()
? error.message
: "Message sent, but assistant reply failed";
addToast(`Message sent, but assistant reply failed: ${message}`, "error");
return;
}
setMessageInput(previousInput);
const message = error instanceof Error && error.message.trim()
? error.message

View File

@@ -5,11 +5,17 @@ import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
import { RoomMessageDeliveredButReplyFailedError, type UseChatRoomsResult } from "../../hooks/useChatRooms";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useChatRooms", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useChatRooms")>();
return {
...actual,
useChatRooms: vi.fn(),
};
});
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
@@ -343,13 +349,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
expect(addToast).not.toHaveBeenCalledWith(expect.stringMatching(/attach/i), "warning");
});
it("keeps room composer text and toasts once when room send fails", async () => {
it("FN-5360 keeps room composer cleared when delivery succeeded but reply generation failed", async () => {
const addToast = vi.fn();
let rejectSend: (error?: unknown) => void;
const sendPromise = new Promise<undefined>((_, reject) => {
rejectSend = reject;
});
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
const sendRoomMessage = vi
.fn()
.mockRejectedValueOnce(new RoomMessageDeliveredButReplyFailedError("No active room responders available", "room-a"));
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
@@ -360,15 +364,46 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] });
});
expect(textarea.value).toBe("");
await waitFor(() => {
expect(textarea.value).toBe("");
});
expect(addToast).toHaveBeenCalledWith("Message sent, but assistant reply failed: No active room responders available", "error");
});
rejectSend!(new Error("Room backend failed"));
it("FN-5360 restores room composer when delivery fails", async () => {
const addToast = vi.fn();
const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed"));
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await userEvent.type(textarea, "Will retry{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry", { files: [] });
});
await waitFor(() => {
expect(textarea.value).toBe("Will retry");
});
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Room backend failed", "error");
expect(addToast).toHaveBeenCalledWith("POST failed", "error");
});
it("clears room composer on Enter when room send succeeds", async () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await userEvent.type(textarea, "Delivered{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Delivered", { files: [] });
});
await waitFor(() => {
expect(textarea.value).toBe("");
});
});
it("clears room composer optimistically before send resolves", async () => {

View File

@@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatRoom, ChatRoomMember, ChatRoomMessage } from "@fusion/core";
import { useChatRooms } from "../useChatRooms";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../useChatRooms";
import * as apiModule from "../../api";
import * as sseBusModule from "../../sse-bus";
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
@@ -335,14 +335,23 @@ describe("useChatRooms", () => {
mockUploadChatRoomAttachment.mockRejectedValueOnce(new Error("Upload failed"));
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
let uploadError: unknown;
await act(async () => {
await expect(result.current.sendRoomMessage("hello", { files: [file] })).rejects.toThrow("Failed to upload attachment: bad.txt");
try {
await result.current.sendRoomMessage("hello", { files: [file] });
} catch (error) {
uploadError = error;
}
});
expect(uploadError).toBeInstanceOf(Error);
expect((uploadError as Error).message).toBe("Failed to upload attachment: bad.txt");
expect(uploadError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
});
it("rolls back optimistic temp message when post fails and transcript refresh fails", async () => {
it("rejects with original error when post fails before delivery", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
@@ -356,10 +365,19 @@ describe("useChatRooms", () => {
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
mockFetchChatRoomMessages.mockRejectedValueOnce(new Error("refresh failed"));
let postError: unknown;
await act(async () => {
await expect(result.current.sendRoomMessage("hello")).rejects.toThrow("POST failed");
try {
await result.current.sendRoomMessage("hello");
} catch (error) {
postError = error;
}
});
expect(postError).toBeInstanceOf(Error);
expect((postError as Error).message).toBe("POST failed");
expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
expect(result.current.messages).toEqual([]);
});
@@ -384,6 +402,41 @@ describe("useChatRooms", () => {
expect(mockFetchChatRoomMessages).toHaveBeenLastCalledWith("room-1", { limit: 100, order: "desc" }, "proj-1");
});
it("wraps post-delivery refresh failures with RoomMessageDeliveredButReplyFailedError", 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") });
mockFetchChatRoomMessages
.mockRejectedValueOnce(new Error("refresh failed"))
.mockResolvedValueOnce({ messages: [roomMessage("msg-user", "room-1", "hello")] });
let deliveryError: unknown;
await act(async () => {
try {
await result.current.sendRoomMessage("hello");
} catch (error) {
deliveryError = error;
}
});
expect(deliveryError).toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
expect(deliveryError).toEqual(
expect.objectContaining({
name: "RoomMessageDeliveredButReplyFailedError",
roomId: "room-1",
message: "refresh failed",
}),
);
});
it("refreshes persisted room messages even when room reply generation fails", async () => { const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));

View File

@@ -16,6 +16,16 @@ import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS,
const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room";
export class RoomMessageDeliveredButReplyFailedError extends Error {
roomId: string;
constructor(message: string, roomId: string) {
super(message);
this.name = "RoomMessageDeliveredButReplyFailedError";
this.roomId = roomId;
}
}
export interface UseChatRoomsResult {
rooms: ChatRoom[];
roomsLoading: boolean;
@@ -205,6 +215,13 @@ export function useChatRooms(
}
}, [activeRoomCacheKey, projectId]);
/**
* Sends a room message with optimistic UI.
*
* 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.
*/
const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => {
const activeRoomSnapshot = activeRoomRef.current;
const roomId = activeRoomSnapshot?.id;
@@ -226,6 +243,8 @@ export function useChatRooms(
setMessages((previous) => [...previous, optimisticMessage]);
}
let userMessageDelivered = false;
try {
const uploadedAttachments: ChatAttachment[] = [];
if (opts?.files?.length) {
@@ -245,6 +264,7 @@ export function useChatRooms(
content,
...(mergedAttachments.length ? { attachments: mergedAttachments } : {}),
}, projectId);
userMessageDelivered = true;
if (postResult.message?.createdAt && activeRoomSnapshot) {
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
@@ -271,6 +291,14 @@ export function useChatRooms(
setMessages((previous) => previous.filter((message) => message.id !== optimisticMessage.id));
}
}
if (userMessageDelivered) {
const message = error instanceof Error && error.message.trim()
? error.message
: "Message delivered, but failed to refresh room replies";
throw new RoomMessageDeliveredButReplyFailedError(message, roomId);
}
throw error;
}
}, [projectId]);