fix(chat): restore room message visibility and responders

This commit is contained in:
gsxdsm
2026-05-11 15:19:16 -07:00
parent 9bb46f5bfa
commit db628e9539
4 changed files with 117 additions and 64 deletions

View File

@@ -224,8 +224,13 @@ describe("useChatRooms", () => {
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("No active room responders available for room room-1")); mockPostChatRoomMessage.mockRejectedValueOnce(new Error("No active room responders available for room room-1"));
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [persistedUserMessage] }); mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [persistedUserMessage] });
await expect(result.current.sendRoomMessage("hello")).rejects.toThrow("No active room responders available for room room-1"); const sendPromise = act(async () => {
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user"]); await expect(result.current.sendRoomMessage("hello")).rejects.toThrow("No active room responders available for room room-1");
});
await sendPromise;
await waitFor(() => {
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user"]);
});
}); });
it("tears down sse subscription on unmount", async () => { it("tears down sse subscription on unmount", async () => {

View File

@@ -174,20 +174,32 @@ export function useChatRooms(
throw new Error("Select a room before sending a message"); throw new Error("Select a room before sending a message");
} }
const postResult = await postChatRoomMessage(roomId, { try {
content, const postResult = await postChatRoomMessage(roomId, {
...(opts?.attachments ? { attachments: opts.attachments } : {}), content,
}, projectId); ...(opts?.attachments ? { attachments: opts.attachments } : {}),
}, projectId);
if (postResult.message?.createdAt && activeRoomSnapshot) { if (postResult.message?.createdAt && activeRoomSnapshot) {
setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt })); setRooms((previous) => upsertRoom(previous, { ...activeRoomSnapshot, updatedAt: postResult.message.createdAt }));
} }
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100 }, projectId); const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100 }, projectId);
if (activeRoomRef.current?.id !== roomId) { if (activeRoomRef.current?.id !== roomId) {
return; return;
}
setMessages(latestMessages.messages);
} catch (error) {
try {
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100 }, projectId);
if (activeRoomRef.current?.id === roomId) {
setMessages(latestMessages.messages);
}
} catch {
// Ignore refresh failures and preserve the original error.
}
throw error;
} }
setMessages(latestMessages.messages);
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { AgentStore, ChatStore, type MessageStore, type TaskStore } from "@fusion/core"; import { AgentStore, ChatStore, type MessageStore, type TaskStore } from "@fusion/core";
import type { PluginRunner, ProjectEngineManager } from "@fusion/engine"; import type { ProjectEngineManager } from "@fusion/engine";
import { ChatManager } from "./chat.js"; import { ChatManager } from "./chat.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js"; import { getOrCreateProjectStore } from "./project-store-resolver.js";
@@ -34,17 +34,24 @@ export async function resolveProjectChatContext(options: {
} }
const engine = engineManager?.getEngine(projectId); const engine = engineManager?.getEngine(projectId);
const scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId); try {
return { const scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
store: scopedStore, return {
chatStore: getOrCreateScopedChatStore(scopedStore), store: scopedStore,
}; chatStore: getOrCreateScopedChatStore(scopedStore),
};
} catch {
return {
store: defaultStore,
chatStore: getOrCreateScopedChatStore(defaultStore, defaultChatStore),
};
}
} }
export async function createProjectScopedChatManager(options: { export async function createProjectScopedChatManager(options: {
store: TaskStore; store: TaskStore;
chatStore: ChatStore; chatStore: ChatStore;
pluginRunner?: PluginRunner; pluginRunner?: ConstructorParameters<typeof ChatManager>[3];
messageStore?: MessageStore; messageStore?: MessageStore;
}): Promise<ChatManager> { }): Promise<ChatManager> {
const agentStore = new AgentStore({ rootDir: options.store.getFusionDir() }); const agentStore = new AgentStore({ rootDir: options.store.getFusionDir() });

View File

@@ -23,11 +23,30 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
async function resolveRoomScopedServices(req: Request, roomProjectId: string | null | undefined) { async function resolveRoomScopedServices(req: Request, roomProjectId: string | null | undefined) {
if (!roomProjectId) { if (!roomProjectId) {
const chatStore = options?.chatStore; const chatStore = options?.chatStore;
const chatManager = options?.chatManager; if (!chatStore) {
if (!chatStore || !chatManager) { throw internalError("Chat store not available");
throw internalError("Chat store or manager not available");
} }
return { store: ctx.store, chatStore, chatManager }; const chatManager = options?.chatManager ?? await createProjectScopedChatManager({
store: ctx.store,
chatStore,
pluginRunner: options?.pluginRunner,
messageStore: options?.engine?.getMessageStore(),
});
return { chatStore, chatManager };
}
if (!options?.engineManager) {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const chatManager = options?.chatManager ?? await createProjectScopedChatManager({
store: ctx.store,
chatStore,
pluginRunner: options?.pluginRunner,
messageStore: options?.engine?.getMessageStore(),
});
return { chatStore, chatManager };
} }
const { store: scopedStore, chatStore } = await resolveProjectChatContext({ const { store: scopedStore, chatStore } = await resolveProjectChatContext({
@@ -36,30 +55,30 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
defaultChatStore: options?.chatStore, defaultChatStore: options?.chatStore,
engineManager: options?.engineManager, engineManager: options?.engineManager,
}); });
const engine = options.engineManager.getEngine(roomProjectId);
if (scopedStore === ctx.store && options?.chatStore && options?.chatManager) {
return { store: ctx.store, chatStore: options.chatStore, chatManager: options.chatManager };
}
const engine = options?.engineManager?.getEngine(roomProjectId);
const chatManager = await createProjectScopedChatManager({ const chatManager = await createProjectScopedChatManager({
store: scopedStore, store: scopedStore,
chatStore, chatStore,
pluginRunner: options?.pluginRunner, pluginRunner: options?.pluginRunner,
messageStore: engine?.getMessageStore(), messageStore: engine?.getMessageStore(),
}); });
return { store: scopedStore, chatStore, chatManager }; return { chatStore, chatManager };
} }
router.get("/chat/rooms", rateLimit(RATE_LIMITS.api), async (req, res) => { router.get("/chat/rooms", rateLimit(RATE_LIMITS.api), async (req, res) => {
try { try {
const projectId = getRequestedProjectId(req); const projectId = getRequestedProjectId(req);
const { chatStore } = await resolveRoomScopedServices(req, projectId); const { chatStore } = await resolveRoomScopedServices(req, projectId);
const { status, agentId } = req.query as { status?: string; agentId?: string }; const { status, agentId } = req.query as {
status?: string;
agentId?: string;
};
const statusFilter = status as ChatRoomStatus | undefined; const statusFilter = status as ChatRoomStatus | undefined;
const rooms = agentId const rooms = agentId
? chatStore.listRoomsForAgent(agentId, { projectId, status: statusFilter }) ? chatStore.listRoomsForAgent(agentId, { projectId, status: statusFilter })
: chatStore.listRooms({ projectId, status: statusFilter }); : chatStore.listRooms({ projectId, status: statusFilter });
res.json({ rooms }); res.json({ rooms });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -76,11 +95,12 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
createdBy?: string | null; createdBy?: string | null;
memberAgentIds?: string[]; memberAgentIds?: string[];
}; };
const { chatStore } = await resolveRoomScopedServices(req, projectId);
if (!name || typeof name !== "string" || !name.trim()) { if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string"); throw badRequest("name is required and must be a non-empty string");
} }
const { chatStore } = await resolveRoomScopedServices(req, projectId);
const roomInput: ChatRoomCreateInput & { memberAgentIds?: string[] } = { const roomInput: ChatRoomCreateInput & { memberAgentIds?: string[] } = {
name: name.trim(), name: name.trim(),
...(description !== undefined ? { description } : {}), ...(description !== undefined ? { description } : {}),
@@ -109,10 +129,11 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.get("/chat/rooms/:id", rateLimit(RATE_LIMITS.api), async (req, res) => { router.get("/chat/rooms/:id", rateLimit(RATE_LIMITS.api), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
const members = chatStore.listRoomMembers(roomId); const members = chatStore.listRoomMembers(roomId);
res.json({ room, members }); res.json({ room, members });
} catch (err: unknown) { } catch (err: unknown) {
@@ -123,13 +144,14 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.patch("/chat/rooms/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.patch("/chat/rooms/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const { name, description, status } = req.body as { name?: string; description?: string | null; status?: ChatRoomStatus }; const { name, description, status } = req.body as { name?: string; description?: string | null; status?: ChatRoomStatus };
if (name === undefined && description === undefined && status === undefined) { if (name === undefined && description === undefined && status === undefined) {
throw badRequest("at least one of name, description, or status is required"); throw badRequest("at least one of name, description, or status is required");
} }
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id);
const input: ChatRoomUpdateInput = { const input: ChatRoomUpdateInput = {
...(name !== undefined ? { name: name.trim() } : {}), ...(name !== undefined ? { name: name.trim() } : {}),
...(description !== undefined ? { description } : {}), ...(description !== undefined ? { description } : {}),
@@ -156,10 +178,11 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.delete("/chat/rooms/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.delete("/chat/rooms/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
chatStore.deleteRoom(roomId); chatStore.deleteRoom(roomId);
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
@@ -170,10 +193,11 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.get("/chat/rooms/:id/members", rateLimit(RATE_LIMITS.api), async (req, res) => { router.get("/chat/rooms/:id/members", rateLimit(RATE_LIMITS.api), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
const members = chatStore.listRoomMembers(roomId); const members = chatStore.listRoomMembers(roomId);
res.json({ members }); res.json({ members });
} catch (err: unknown) { } catch (err: unknown) {
@@ -184,8 +208,8 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.post("/chat/rooms/:id/members", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.post("/chat/rooms/:id/members", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
@@ -207,11 +231,12 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.delete("/chat/rooms/:id/members/:agentId", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.delete("/chat/rooms/:id/members/:agentId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const agentId = String(req.params.agentId); const agentId = String(req.params.agentId);
const removed = chatStore.removeRoomMember(roomId, agentId); const removed = chatStore.removeRoomMember(roomId, agentId);
if (!removed) throw notFound(`Room member ${agentId} not found in room ${roomId}`); if (!removed) throw notFound(`Room member ${agentId} not found in room ${roomId}`);
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -221,8 +246,8 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.get("/chat/rooms/:id/messages", rateLimit(RATE_LIMITS.api), async (req, res) => { router.get("/chat/rooms/:id/messages", rateLimit(RATE_LIMITS.api), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
@@ -237,6 +262,7 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
offset, offset,
...(before ? { before } : {}), ...(before ? { before } : {}),
}); });
res.json({ messages }); res.json({ messages });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -247,16 +273,18 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.post("/chat/rooms/:id/messages", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.post("/chat/rooms/:id/messages", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const roomId = String(req.params.id); const roomId = String(req.params.id);
const requestedProjectId = getRequestedProjectId(req); const hintedProjectId = getRequestedProjectId(req);
const { chatStore } = await resolveRoomScopedServices(req, requestedProjectId); const services = await resolveRoomScopedServices(req, hintedProjectId);
const room = chatStore.getRoom(roomId); const room = services.chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
const { content, senderAgentId, attachments } = req.body as { const { content, senderAgentId, attachments } = req.body as {
content?: string; content?: string;
senderAgentId?: string | null; senderAgentId?: string | null;
mentions?: string[];
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
}; };
if (!content || typeof content !== "string" || !content.trim()) { if (!content || typeof content !== "string" || !content.trim()) {
throw badRequest("content is required and must be a non-empty string"); throw badRequest("content is required and must be a non-empty string");
} }
@@ -264,8 +292,7 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
throw badRequest("senderAgentId is reserved for FN-3810; must be null or omitted"); throw badRequest("senderAgentId is reserved for FN-3810; must be null or omitted");
} }
const { chatManager } = await resolveRoomScopedServices(req, room.projectId); const result = await services.chatManager.sendRoomMessage(roomId, content.trim(), Array.isArray(attachments) ? attachments : undefined);
const result = await chatManager.sendRoomMessage(roomId, content.trim(), Array.isArray(attachments) ? attachments : undefined);
res.status(201).json({ message: result.userMessage }); res.status(201).json({ message: result.userMessage });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;
@@ -278,15 +305,17 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.delete("/chat/rooms/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.delete("/chat/rooms/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const messageId = String(req.params.messageId); const messageId = String(req.params.messageId);
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
const message = chatStore.getRoomMessage(messageId); const message = chatStore.getRoomMessage(messageId);
if (!message || message.roomId !== roomId) { if (!message || message.roomId !== roomId) {
throw notFound(`Message ${messageId} not found`); throw notFound(`Message ${messageId} not found`);
} }
chatStore.deleteRoomMessage(messageId); chatStore.deleteRoomMessage(messageId);
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
@@ -297,11 +326,12 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
router.post("/chat/rooms/:id/messages/:messageId/attachments", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.post("/chat/rooms/:id/messages/:messageId/attachments", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const roomId = String(req.params.id); const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const messageId = String(req.params.messageId); const messageId = String(req.params.messageId);
const room = chatStore.getRoom(roomId); const room = chatStore.getRoom(roomId);
if (!room) throw notFound(`Chat room ${roomId} not found`); if (!room) throw notFound(`Chat room ${roomId} not found`);
const message = chatStore.getRoomMessage(messageId); const message = chatStore.getRoomMessage(messageId);
if (!message || message.roomId !== roomId) { if (!message || message.roomId !== roomId) {
throw notFound(`Message ${messageId} not found`); throw notFound(`Message ${messageId} not found`);
@@ -321,21 +351,20 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
}); });
if (process.env.FUSION_DEBUG_CHAT_ROUTES === "1") { if (process.env.FUSION_DEBUG_CHAT_ROUTES === "1") {
chatLogger.info("room routes registered", { const chatRoomRoutes = [
chatRoomRoutes: [ "GET /chat/rooms",
"GET /chat/rooms", "POST /chat/rooms",
"POST /chat/rooms", "GET /chat/rooms/:id",
"GET /chat/rooms/:id", "PATCH /chat/rooms/:id",
"PATCH /chat/rooms/:id", "DELETE /chat/rooms/:id",
"DELETE /chat/rooms/:id", "GET /chat/rooms/:id/members",
"GET /chat/rooms/:id/members", "POST /chat/rooms/:id/members",
"POST /chat/rooms/:id/members", "DELETE /chat/rooms/:id/members/:agentId",
"DELETE /chat/rooms/:id/members/:agentId", "GET /chat/rooms/:id/messages",
"GET /chat/rooms/:id/messages", "POST /chat/rooms/:id/messages",
"POST /chat/rooms/:id/messages", "DELETE /chat/rooms/:id/messages/:messageId",
"DELETE /chat/rooms/:id/messages/:messageId", "POST /chat/rooms/:id/messages/:messageId/attachments",
"POST /chat/rooms/:id/messages/:messageId/attachments", ];
], chatLogger.info("room routes registered", { chatRoomRoutes });
});
} }
} }