feat(FN-3981): document room message resync contract and reproduce reply vi
Fixes a room reply visibility gap in the chat rooms hook by correcting the message-resync condition and adding test coverage to reproduce the issue, with updated documentation for the resync contract. Fusion-Task-Id: FN-3981
This commit is contained in:
@@ -282,7 +282,7 @@ Intentional exclusions from shared snapshots:
|
||||
- The hook subscribes to `/api/events` and consumes `chat:room:created`, `chat:room:updated`, `chat:room:deleted`, `chat:room:member:added`, `chat:room:member:removed`, `chat:room:message:added`, `chat:room:message:updated`, and `chat:room:message:deleted` to keep UI state in sync.
|
||||
- Room messages persist through `POST /api/chat/rooms/:id/messages`; the route persists the user message first, then calls `ChatManager.sendRoomMessage(...)` to orchestrate room-member responders and persist assistant room replies with `chatStore.addRoomMessage(...)`.
|
||||
- `sendRoomMessage(...)` uses existing room-member + mention resolution rules: mentioned members are direct responders, non-mentioned members are ambient responders (capped by `ROOM_AMBIENT_MAX_RESPONDERS`), and non-member mentions are handled explicitly by the manager instead of silently disappearing.
|
||||
- UI does not optimistically insert room messages; it renders persisted user + assistant room messages from `chat:room:message:*` SSE events.
|
||||
- UI does not optimistically insert room messages; `useChatRooms.sendRoomMessage()` re-fetches authoritative room messages immediately after `POST /api/chat/rooms/:id/messages` so persisted assistant replies remain visible across SSE timing gaps, then continues applying `chat:room:message:*` events for live updates.
|
||||
- Mention UI in rooms keeps direct-chat behavior unchanged while adding room affordances:
|
||||
- `AgentMentionPopup` receives room membership context and shows members first with a `status-dot` member indicator (`aria-label="Room member"`).
|
||||
- With an empty mention filter in room mode, only room members are listed; a hint row prompts the user to type to search non-members.
|
||||
|
||||
@@ -114,7 +114,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
|
||||
- After a successful room send, the room composer is cleared (matching direct-chat composer behavior) so stale text is not left in the input.
|
||||
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
|
||||
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
|
||||
- The UI still avoids optimistic room echo; it renders both the persisted user message and persisted assistant room replies from `chat:room:message:*` SSE events, so room threads stay server-authoritative.
|
||||
- The UI still avoids optimistic room echo; after `POST /api/chat/rooms/:id/messages`, it immediately re-fetches authoritative room messages to surface persisted user/assistant replies even if SSE delivery is delayed, and it continues to apply `chat:room:message:*` SSE updates for live fan-out.
|
||||
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
|
||||
- For backend details, see the [Chat Room REST API reference](./architecture.md#real-time-channels) and the [chat room storage schema (`chat_rooms`, `chat_room_members`, `chat_room_messages`)](./storage.md#chat-rooms-migration-70).
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("useChatRooms", () => {
|
||||
expect(result.current.activeRoom).toBeNull();
|
||||
});
|
||||
|
||||
it("sendRoomMessage posts without optimistic insert", async () => {
|
||||
it("sendRoomMessage resyncs room messages from server after post", async () => {
|
||||
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
||||
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
||||
const { result } = renderHook(() => useChatRooms("proj-1"));
|
||||
@@ -193,17 +193,20 @@ describe("useChatRooms", () => {
|
||||
act(() => result.current.selectRoom("room-1"));
|
||||
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
|
||||
|
||||
mockFetchChatRoomMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
roomMessage("msg-user", "room-1", "hello"),
|
||||
{ ...roomMessage("msg-assistant", "room-1", "Room reply"), role: "assistant", senderAgentId: "agent-1" },
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendRoomMessage("hello");
|
||||
});
|
||||
|
||||
expect(mockPostChatRoomMessage).toHaveBeenCalledWith("room-1", { content: "hello" }, "proj-1");
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
capturedEvents["chat:room:message:added"]?.({ data: JSON.stringify(roomMessage("msg-1", "room-1", "hello")) } as MessageEvent);
|
||||
});
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(mockFetchChatRoomMessages).toHaveBeenLastCalledWith("room-1", { limit: 100 }, "proj-1");
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user", "msg-assistant"]);
|
||||
});
|
||||
|
||||
it("tears down sse subscription on unmount", async () => {
|
||||
|
||||
@@ -168,15 +168,26 @@ export function useChatRooms(
|
||||
}, [projectId]);
|
||||
|
||||
const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[] }) => {
|
||||
const roomId = activeRoomRef.current?.id;
|
||||
const activeRoomSnapshot = activeRoomRef.current;
|
||||
const roomId = activeRoomSnapshot?.id;
|
||||
if (!roomId) {
|
||||
throw new Error("Select a room before sending a message");
|
||||
}
|
||||
|
||||
await postChatRoomMessage(roomId, {
|
||||
const postResult = await postChatRoomMessage(roomId, {
|
||||
content,
|
||||
...(opts?.attachments ? { attachments: opts.attachments } : {}),
|
||||
}, projectId);
|
||||
|
||||
if (postResult.message?.createdAt && activeRoomSnapshot) {
|
||||
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
|
||||
}
|
||||
|
||||
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100 }, projectId);
|
||||
if (activeRoomRef.current?.id !== roomId) {
|
||||
return;
|
||||
}
|
||||
setMessages(latestMessages.messages);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user