feat(FN-3926): add chat room integration tests across core and dashboard

This merge adds comprehensive test coverage for chat room functionality across the core store, ChatView component, HTTP/SSE routes, and integration layer (845 lines added across four test files), guided by a test plan for FN-3812.

Fusion-Task-Id: FN-3926
This commit is contained in:
Fusion
2026-05-10 11:46:58 -07:00
committed by gsxdsm
parent 83fb0e722f
commit d8fef5e4a4
5 changed files with 833 additions and 92 deletions

View File

@@ -1,67 +1,72 @@
# FN-3812 Room Test Plan # FN-3812 Room Test Plan (Rebuilt for FN-3926)
This plan defines contract-neutral coverage for room creation, room switching, persisted history, mention routing, and hybrid room response behavior. Each bullet below maps 1:1 to a single `it.todo(...)` title in the matching scaffold file. ## Preflight ownership decisions
## Layer 1 — Core chat-store (persistence) - The four dedicated room suites already existed as `it.todo` scaffolds and are being populated with real assertions.
- Existing room coverage in broad suites is **kept** (Leave + Annotate) unless duplication becomes confusing or brittle.
- `chat-routes.rooms.test.ts` will focus on missing room HTTP assertions and room SSE payload/cleanup assertions; legacy `chat-room-routes.test.ts` and `sse-chat-rooms.test.ts` remain valid broad/smoke coverage.
- FN-3812 prompt file was not present in this worktree, so this rebuilt plan is derived from shipped source contracts.
### Room lifecycle and membership ## Layer 1 — Core store (`chat-store.rooms.test.ts`)
- room creation persists a new room record with creator context and retrievable metadata — creating a room must make it available to later room reads/lists.
- member add/remove updates room membership deterministically — adding a member makes them present in membership reads and removing them makes them absent.
### Room message persistence and retrieval - `createRoom` normalizes `#Engineering Team` to `name: "Engineering Team"`, `slug: "engineering-team"`.
- room-scoped append + list preserves message order and payload fields — appending messages to a room and listing them must return them in stable chronological order with stored content. - `createdBy` member receives `role: "owner"`; other listed members receive `"member"`.
- cross-room isolation keeps each room history independent — reading room A history must never include messages appended to room B. - Same-project slug collisions throw; same slug in different project is allowed.
- room-vs-direct isolation keeps room history separate from direct sessions — room message reads must not surface direct-chat messages, and direct message reads must not surface room messages. - `getRoom`, `getRoomBySlug`, `listRooms`, `updateRoom`, `deleteRoom` lifecycle assertions.
- `addRoomMember` idempotency and `removeRoomMember` true/false behavior.
- `listRoomsForAgent` respects membership/project/status filters.
- `deleteRoom` cascades room members and room messages.
- `addRoomMessage` + `getRoomMessages({ before })` preserve timeline and cursor behavior.
- `mentions` metadata round-trips via `addRoomMessage` / `getRoomMessage`.
- `addRoomMessageAttachment` appends attachment metadata and emits updated message.
- Room event emission: created/updated/deleted/member added/member removed/message added/message updated/message deleted.
### Persistence round-trip and metadata fidelity ## Layer 2 — Orchestration (`chat.rooms.test.ts`)
- close/reopen round-trip preserves room, membership, and room history state — reopening the store/database must return the same room data and history without loss.
- mention metadata round-trip persists and rehydrates mention routing context — stored mention markers on room messages must be returned unchanged on read.
- responder metadata round-trip persists and rehydrates responder attribution — stored responder identity/role markers on assistant room messages must be returned unchanged on read.
## Layer 2 — Chat orchestration (routing + dispatch) - `resolveRoomResponders` returns direct/ambient/nonMemberMentions partitions.
- Mentioned room members are direct responders; remaining room members become ambient responders.
- Non-member mentions are excluded from responders and returned in `nonMemberMentions`.
- Duplicate mentions dedupe to one direct responder.
- `sendRoomMessage` persists user room message + assistant replies for resolved responders.
- `sendRoomMessage` emits non-member explanatory assistant note when non-member mentions are present.
### Mention routing in rooms ## Layer 3 — HTTP + SSE (`chat-routes.rooms.test.ts`)
- direct mention in room routes targeted response from addressed room member — mentioning a room member should produce a targeted responder output from that member.
- non-member mention behavior does not dispatch to out-of-room agents and surfaces explicit feedback — mentioning an agent outside the room should not trigger that agent and should produce a user-visible non-member notice.
### Hybrid dispatch behavior - Room CRUD + member route contract assertions:
- hybrid ambient response includes non-mentioned room members when room dispatch mode allows ambient participation — room messages with mentions can still trigger additional ambient responders. - create/list/get/update/delete success paths
- mention suppresses ambient on the addressed agent to avoid duplicate responses — the directly mentioned agent should respond once, not once direct plus once ambient. - missing name => 400
- same-project slug collision => 409
- unknown room => 404
- member add/remove including remove-not-found 404
- Message route contract assertions:
- POST trims content and rejects `senderAgentId` unless null/omitted
- POST path uses injected `chatManager.sendRoomMessage` assistant-reply workflow
- pagination and `before` behavior
- delete message is idempotent via 404 on repeat
- attachment metadata route updates room message attachments
- Room SSE assertions against real `ChatStore`:
- `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`, `chat:room:message:deleted`
- deleted room/message payloads are wrapper objects `{ id }`
- cleanup leaves `EventEmitter.listenerCount(chatStore, event) === 0` after close
### Regression guard ## Layer 4 — UI (`ChatView.rooms.test.tsx`)
- direct-chat regression guard keeps legacy direct send path unchanged — non-room chat routing should continue to behave as before room support.
## Layer 3 — HTTP + SSE - Rooms scope toggle renders and switches Direct/Rooms (`chat-sidebar-scope-*`).
- Room list selection switches active room without message leakage.
- Create-room modal submit calls `createRoom` and updates active room context.
- Mobile room mode back button returns from thread to sidebar.
- Room send path uses `sendRoomMessage` (Enter and send button).
- Delete-room confirmation confirm/cancel behavior.
- Hook-driven room message rerender when room message state updates.
- Direct-mode regression guard: direct scope still uses `sendMessage` path.
- FN-3811 mention/member-order note: assert only behavior exposed by current UI contract; do not invent additional mention ordering behavior.
### Room API endpoints ## Broad suite cross-reference (kept coverage)
- room create + list endpoints return created room and include it in subsequent listings — API callers can create a room then retrieve it via list/read endpoints.
- per-room history read returns only the selected room timeline — room history endpoint must scope results to the requested room.
- send room message with mention records mention data and triggers routed responder behavior — room send endpoint must accept mention text and emit resulting room messages.
### Streaming scope and permissions - `packages/core/src/__tests__/chat-store.test.ts` retains broad room CRUD/message smoke.
- SSE room channel scoping delivers events only to matching room subscribers (`it.each` over A↔B subscribers) — a subscriber for room A receives A events and not B events, and vice versa. - `packages/dashboard/src/__tests__/chat-manager-room-hybrid.test.ts` remains helper-focused room responder suite.
- v1 permissions allow same-project room operations without cross-user 403 checks — current project-scoped room routes should not reject same-project callers for cross-user constraints. - `packages/dashboard/src/__tests__/chat-room-routes.test.ts` retains broad room route coverage.
- `packages/dashboard/src/__tests__/sse-chat-rooms.test.ts` retains broad SSE room smoke.
## Layer 4 — Dashboard ChatView (UI) - `packages/dashboard/app/components/__tests__/ChatView.test.tsx` retains direct-chat + mixed integration coverage.
### Mode and navigation
- Direct/Rooms toggle render exposes both scopes when room mode is enabled — users should see and switch between Direct and Rooms modes.
- room switching loads selected room history without leakage (`it.each` A↔B matrix) — switching rooms should show only the selected room thread and no carryover from other rooms.
### Mention UX in room mode
- mention popup in room mode prioritizes room members before non-members when filtering — member suggestions appear first for the same query.
- non-member mention chip class marks out-of-room mentions in rendered messages — rendered mention chips for non-members should include the non-member styling/state marker.
### Persistence + regression
- persisted history survives remount and reload in room mode — room thread content should still render after component remount/re-init.
- direct-chat parity regression guard keeps direct mode behavior unchanged in the same view — existing direct-chat composer/render/send behavior remains intact.
## Handoff: converting `it.todo` to real assertions
1. Re-read the merged FN-3805..FN-3811 implementation across core store, orchestration, HTTP/SSE routes, and ChatView to confirm the final shipped contracts.
2. Record actual discovered symbols (type names, method names, route paths, SSE event names, prop names, CSS class names) next to each planned assertion before editing test bodies.
3. Replace each `it.todo("...")` entry with a concrete `it("...", async () => { ... })` assertion against the merged contract; keep behavior-focused titles but make them implementation-specific where necessary.
4. Run targeted suites first (the four rooms scaffold files and adjacent existing room tests), then run `pnpm test` to validate whole-workspace integration.
5. Update this plan with any contract surprises or changed assumptions found during conversion so future maintainers can trace why assertions differ from the original stub wording.
**Do not weaken coverage:** every `it.todo` in these scaffolds must become a real assertion (or a stricter split/consolidation with `it.each` for true matrices). Coverage shrinkage is not acceptable except legitimate matrix consolidation.

View File

@@ -1,20 +1,168 @@
import { describe, it } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { ChatStore } from "../chat-store.js";
import { Database } from "../db.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-chat-store-rooms-test-"));
}
describe("ChatStore — rooms (FN-3805..FN-3811 contract)", () => { describe("ChatStore — rooms (FN-3805..FN-3811 contract)", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let store: ChatStore;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new ChatStore(fusionDir, db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
describe("Room lifecycle and membership", () => { describe("Room lifecycle and membership", () => {
it.todo("room creation persists a new room record with creator context and retrievable metadata"); it("normalizes slug, assigns owner/member roles, and supports room lifecycle lookups", () => {
it.todo("member add/remove updates room membership deterministically"); const room = store.createRoom({
name: "#Engineering Team",
projectId: "proj-1",
createdBy: "agent-owner",
memberAgentIds: ["agent-owner", "agent-2"],
});
expect(room.name).toBe("Engineering Team");
expect(room.slug).toBe("engineering-team");
const members = store.listRoomMembers(room.id);
expect(members.find((m) => m.agentId === "agent-owner")?.role).toBe("owner");
expect(members.find((m) => m.agentId === "agent-2")?.role).toBe("member");
expect(store.getRoom(room.id)?.id).toBe(room.id);
expect(store.getRoomBySlug("proj-1", "engineering-team")?.id).toBe(room.id);
const updated = store.updateRoom(room.id, { name: "#Engineering Core", description: "core", status: "archived" });
expect(updated?.slug).toBe("engineering-core");
expect(updated?.status).toBe("archived");
expect(store.deleteRoom(room.id)).toBe(true);
expect(store.getRoom(room.id)).toBeUndefined();
});
it("rejects same-project slug collision while allowing cross-project duplicates", () => {
store.createRoom({ name: "engineering", projectId: "proj-1" });
expect(() => store.createRoom({ name: "#Engineering", projectId: "proj-1" })).toThrow("already exists");
expect(() => store.createRoom({ name: "#Engineering", projectId: "proj-2" })).not.toThrow();
});
it("keeps member add idempotent, supports removal, listRoomsForAgent filters, and cascades delete", () => {
const room = store.createRoom({ name: "ops", projectId: "proj-1", createdBy: "agent-1" });
store.addRoomMember(room.id, "agent-2");
store.addRoomMember(room.id, "agent-2");
expect(store.listRoomMembers(room.id).filter((m) => m.agentId === "agent-2")).toHaveLength(1);
const archived = store.updateRoom(room.id, { status: "archived" });
expect(archived?.status).toBe("archived");
expect(store.listRoomsForAgent("agent-2", { projectId: "proj-1", status: "archived" })).toHaveLength(1);
expect(store.removeRoomMember(room.id, "agent-2")).toBe(true);
expect(store.removeRoomMember(room.id, "agent-2")).toBe(false);
store.addRoomMember(room.id, "agent-3");
store.addRoomMessage(room.id, { role: "user", content: "hello", mentions: ["agent-3"] });
store.deleteRoom(room.id);
expect(store.listRoomMembers(room.id)).toHaveLength(0);
expect(store.getRoomMessages(room.id)).toHaveLength(0);
});
}); });
describe("Room message persistence and retrieval", () => { describe("Room message persistence and retrieval", () => {
it.todo("room-scoped append + list preserves message order and payload fields"); it("supports timeline, before cursor, mention round-trip, and attachment append", async () => {
it.todo("cross-room isolation keeps each room history independent"); const room = store.createRoom({ name: "support", projectId: "proj-1" });
it.todo("room-vs-direct isolation keeps room history separate from direct sessions"); const first = store.addRoomMessage(room.id, { role: "user", content: "first", mentions: ["agent-1"] });
await new Promise((r) => setTimeout(r, 5));
const second = store.addRoomMessage(room.id, { role: "assistant", content: "second", senderAgentId: "agent-1" });
expect(store.getRoomMessage(first.id)?.mentions).toEqual(["agent-1"]);
expect(store.getRoomMessages(room.id, { before: second.createdAt }).map((m) => m.id)).toEqual([first.id]);
const updated = store.addRoomMessageAttachment(room.id, second.id, {
id: "att-room",
filename: "room.txt",
originalName: "room.txt",
mimeType: "text/plain",
size: 10,
createdAt: new Date().toISOString(),
});
expect(updated.attachments?.[0]?.id).toBe("att-room");
});
it("keeps cross-room and direct-vs-room histories isolated", () => {
const session = store.createSession({ agentId: "agent-1" });
store.addMessage(session.id, { role: "user", content: "direct" });
const roomA = store.createRoom({ name: "room-a" });
const roomB = store.createRoom({ name: "room-b" });
store.addRoomMessage(roomA.id, { role: "user", content: "a1" });
store.addRoomMessage(roomB.id, { role: "user", content: "b1" });
expect(store.getRoomMessages(roomA.id).map((m) => m.content)).toEqual(["a1"]);
expect(store.getRoomMessages(roomB.id).map((m) => m.content)).toEqual(["b1"]);
expect(store.getMessages(session.id).map((m) => m.content)).toEqual(["direct"]);
});
}); });
describe("Persistence round-trip and metadata fidelity", () => { describe("Room events", () => {
it.todo("close/reopen round-trip preserves room, membership, and room history state"); it("emits room lifecycle/member/message events", () => {
it.todo("mention metadata round-trip persists and rehydrates mention routing context"); const created = vi.fn();
it.todo("responder metadata round-trip persists and rehydrates responder attribution"); const updated = vi.fn();
const deleted = vi.fn();
const memberAdded = vi.fn();
const memberRemoved = vi.fn();
const messageAdded = vi.fn();
const messageUpdated = vi.fn();
const messageDeleted = vi.fn();
store.on("chat:room:created", created);
store.on("chat:room:updated", updated);
store.on("chat:room:deleted", deleted);
store.on("chat:room:member:added", memberAdded);
store.on("chat:room:member:removed", memberRemoved);
store.on("chat:room:message:added", messageAdded);
store.on("chat:room:message:updated", messageUpdated);
store.on("chat:room:message:deleted", messageDeleted);
const room = store.createRoom({ name: "events", createdBy: "agent-1", memberAgentIds: ["agent-1"] });
const roomUpdate = store.updateRoom(room.id, { description: "updated" });
const member = store.addRoomMember(room.id, "agent-2");
const message = store.addRoomMessage(room.id, { role: "user", content: "hi" });
const msgUpdate = store.addRoomMessageAttachment(room.id, message.id, {
id: "att-1",
filename: "a.txt",
originalName: "a.txt",
mimeType: "text/plain",
size: 1,
createdAt: new Date().toISOString(),
});
store.removeRoomMember(room.id, "agent-2");
store.deleteRoomMessage(message.id);
store.deleteRoom(room.id);
expect(created).toHaveBeenCalledWith(room);
expect(updated).toHaveBeenCalledWith(roomUpdate);
expect(memberAdded).toHaveBeenCalledWith(member);
expect(messageAdded).toHaveBeenCalledWith(message);
expect(messageUpdated).toHaveBeenCalledWith(msgUpdate);
expect(memberRemoved).toHaveBeenCalledWith({ roomId: room.id, agentId: "agent-2" });
expect(messageDeleted).toHaveBeenCalledWith(message.id);
expect(deleted).toHaveBeenCalledWith(room.id);
});
}); });
}); });

View File

@@ -1,18 +1,223 @@
import { describe, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
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";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
};
});
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
const activeSession: ChatSessionInfo = {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const defaultChatState: UseChatReturn = {
sessions: [activeSession],
activeSession,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [activeSession],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
const roomA = {
id: "room-a",
name: "Room A",
slug: "room-a",
description: null,
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const defaultRoomsState: UseChatRoomsResult = {
rooms: [roomA],
roomsLoading: false,
roomsError: null,
activeRoom: roomA,
activeRoomMembers: [],
messages: [{ id: "rmsg-1", roomId: "room-a", role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] }],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
refreshRooms: vi.fn(),
};
function setup(chatOverrides: Partial<UseChatReturn> = {}, roomsOverrides: Partial<UseChatRoomsResult> = {}) {
mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides });
mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides });
}
function mockMobileViewport() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
}
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
describe("Mode and navigation", () => { beforeEach(() => {
it.todo("Direct/Rooms toggle render exposes both scopes when room mode is enabled"); vi.clearAllMocks();
it.todo("room switching loads selected room history without leakage (it.each A↔B matrix)"); if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
}
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
localStorage.setItem("fusion:chat-scope", "rooms");
setup();
}); });
describe("Mention UX in room mode", () => { it("renders Direct/Rooms toggle and allows room selection without message leakage", async () => {
it.todo("mention popup in room mode prioritizes room members before non-members when filtering"); const selectRoom = vi.fn();
it.todo("non-member mention chip class marks out-of-room mentions in rendered messages"); const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { rooms: [roomA, roomB], selectRoom });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-direct")).toBeInTheDocument();
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
expect(screen.getByText("Room hello")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-room-item-room-b"));
expect(selectRoom).toHaveBeenCalledWith("room-b");
}); });
describe("Persistence + regression", () => { it("creates room via modal and sends room message on Enter", async () => {
it.todo("persisted history survives remount and reload in room mode"); const createRoom = vi.fn().mockResolvedValue({ ...roomA, id: "room-new", name: "Room New", slug: "room-new" });
it.todo("direct-chat parity regression guard keeps direct mode behavior unchanged in the same view"); const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { createRoom, sendRoomMessage });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "room-new");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
const modal = screen.getByRole("dialog", { name: "Create room" });
await userEvent.click(within(modal).getByRole("button", { name: "Create room" }));
await waitFor(() => {
expect(createRoom).toHaveBeenCalledWith({ name: "room-new", memberAgentIds: ["agent-1"] });
});
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Hello room{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Hello room");
});
});
it("supports delete-room confirm/cancel and rerenders messages from hook state", async () => {
const deleteRoom = vi.fn().mockResolvedValue(undefined);
const rerenderedRooms = {
...defaultRoomsState,
messages: [{ id: "rmsg-2", roomId: "room-a", role: "assistant", content: "Updated room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-2", mentions: [] }],
deleteRoom,
};
mockUseChat.mockReturnValue(defaultChatState);
mockUseChatRooms
.mockReturnValueOnce({ ...defaultRoomsState, deleteRoom })
.mockReturnValue(rerenderedRooms);
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-delete-room-a"));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(deleteRoom).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId("chat-room-delete-room-a"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(deleteRoom).toHaveBeenCalledWith("room-a");
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByText("Updated room reply")).toBeInTheDocument();
});
it("shows mobile back button in room thread view", () => {
const mediaSpy = mockMobileViewport();
setup();
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
mediaSpy.mockRestore();
});
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
localStorage.setItem("fusion:chat-scope", "direct");
const sendMessage = vi.fn();
const sendRoomMessage = vi.fn();
setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Direct hello{enter}");
expect(sendMessage).toHaveBeenCalledWith("Direct hello", []);
expect(sendRoomMessage).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,14 +1,288 @@
import { describe, it } from "vitest"; import { EventEmitter } from "node:events";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
import { ChatStore, Database } from "@fusion/core";
import type { TaskStore } from "@fusion/core";
import { request } from "../test-request.js";
import { createSSE } from "../sse.js";
class MockStore {
constructor(private readonly rootDir: string, private readonly db: Database) {}
getRootDir(): string { return this.rootDir; }
getFusionDir(): string { return join(this.rootDir, ".fusion"); }
getKbDir(): string { return join(this.rootDir, ".fusion"); }
getDatabase(): Database { return this.db; }
}
class MockSocket extends EventEmitter {
destroyed = false;
setKeepAlive = vi.fn();
destroy = vi.fn(() => {
if (this.destroyed) return;
this.destroyed = true;
this.emit("close");
});
}
class MockResponse extends EventEmitter {
headers = new Map<string, string>();
writableEnded = false;
destroyed = false;
write = vi.fn();
flushHeaders = vi.fn();
end = vi.fn(() => {
if (this.writableEnded) return;
this.writableEnded = true;
this.emit("close");
});
constructor(readonly socket: MockSocket) {
super();
}
setHeader(name: string, value: string): void {
this.headers.set(name, value);
}
}
function createMockTaskStore(): TaskStore {
const researchStore = {
on: vi.fn(),
off: vi.fn(),
};
return {
on: vi.fn(),
off: vi.fn(),
getResearchStore: vi.fn(() => researchStore),
} as unknown as TaskStore;
}
function openSseConnection(chatStore: ChatStore) {
const store = createMockTaskStore();
const socket = new MockSocket();
const req = new EventEmitter() as Request & { query: Record<string, string>; socket: MockSocket };
req.query = { clientId: "chat-room-events" };
req.socket = socket;
const res = new MockResponse(socket);
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(
req,
res as unknown as Response,
);
return { req, res };
}
describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => { describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => {
describe("Room API endpoints", () => { let tempRoot: string;
it.todo("room create + list endpoints return created room and include it in subsequent listings"); let db: Database;
it.todo("per-room history read returns only the selected room timeline"); let store: MockStore;
it.todo("send room message with mention records mention data and triggers routed responder behavior"); let chatStore: ChatStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
tempRoot = mkdtempSync(join(tmpdir(), "fusion-chat-routes-rooms-"));
const fusionDir = join(tempRoot, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new MockStore(tempRoot, db);
chatStore = new ChatStore(fusionDir, db);
const { createServer } = await import("../server.js");
app = createServer(store as any, { chatStore });
}); });
describe("Streaming scope and permissions", () => { afterEach(async () => {
it.todo("SSE room channel scoping delivers events only to matching room subscribers (it.each over A↔B subscribers)"); db.close();
it.todo("v1 permissions allow same-project room operations without cross-user 403 checks"); await rm(tempRoot, { recursive: true, force: true });
});
it("covers room CRUD/member routes including validation and slug collisions", async () => {
const missingName = await request(app, "POST", "/api/chat/rooms", JSON.stringify({}), {
"content-type": "application/json",
});
expect(missingName.status).toBe(400);
const first = await request(app, "POST", "/api/chat/rooms", JSON.stringify({
name: "Platform Team",
projectId: "p1",
createdBy: "agent-owner",
memberAgentIds: ["agent-owner", "agent-2"],
}), { "content-type": "application/json" });
expect(first.status).toBe(201);
const roomId = (first.body as any).room.id as string;
const duplicate = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "platform-team", projectId: "p1" }), {
"content-type": "application/json",
});
expect(duplicate.status).toBe(409);
const sameSlugOtherProject = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "platform-team", projectId: "p2" }), {
"content-type": "application/json",
});
expect(sameSlugOtherProject.status).toBe(201);
const listByAgent = await request(app, "GET", "/api/chat/rooms?projectId=p1&agentId=agent-2");
expect(listByAgent.status).toBe(200);
expect((listByAgent.body as any).rooms).toHaveLength(1);
const unknownRoom = await request(app, "GET", "/api/chat/rooms/room-missing");
expect(unknownRoom.status).toBe(404);
const addMember = await request(
app,
"POST",
`/api/chat/rooms/${roomId}/members`,
JSON.stringify({ agentId: "agent-3", role: "member" }),
{ "content-type": "application/json" },
);
expect(addMember.status).toBe(201);
const removeMember = await request(app, "DELETE", `/api/chat/rooms/${roomId}/members/agent-3`);
expect(removeMember.status).toBe(200);
const removeMemberAgain = await request(app, "DELETE", `/api/chat/rooms/${roomId}/members/agent-3`);
expect(removeMemberAgain.status).toBe(404);
});
it("covers room message route contracts: trim, senderAgentId rejection, before cursor, delete idempotency, attachments", async () => {
const { createServer } = await import("../server.js");
const appWithRoomReplies = createServer(store as any, {
chatStore,
chatManager: {
sendRoomMessage: async (roomId: string, content: string, attachments?: any[]) => {
const userMessage = chatStore.addRoomMessage(roomId, {
role: "user",
content,
senderAgentId: null,
mentions: ["agent-room"],
...(Array.isArray(attachments) ? { attachments } : {}),
});
chatStore.addRoomMessage(roomId, {
role: "assistant",
content: "room reply",
senderAgentId: "agent-room",
mentions: ["agent-room"],
});
return { userMessage, responders: ["agent-room"] };
},
} as any,
});
const createRoomRes = await request(appWithRoomReplies, "POST", "/api/chat/rooms", JSON.stringify({ name: "Product" }), {
"content-type": "application/json",
});
const roomId = (createRoomRes.body as any).room.id as string;
const postRes = await request(
appWithRoomReplies,
"POST",
`/api/chat/rooms/${roomId}/messages`,
JSON.stringify({ content: " hello @agent_room " }),
{ "content-type": "application/json" },
);
expect(postRes.status).toBe(201);
const messageId = (postRes.body as any).message.id as string;
const persisted = chatStore.getRoomMessage(messageId);
expect(persisted?.content).toBe("hello @agent_room");
const invalidSender = await request(
appWithRoomReplies,
"POST",
`/api/chat/rooms/${roomId}/messages`,
JSON.stringify({ content: "x", senderAgentId: "agent-1" }),
{ "content-type": "application/json" },
);
expect(invalidSender.status).toBe(400);
const first = chatStore.addRoomMessage(roomId, { role: "user", content: "first" });
await new Promise((r) => setTimeout(r, 5));
const second = chatStore.addRoomMessage(roomId, { role: "user", content: "second" });
await new Promise((r) => setTimeout(r, 5));
chatStore.addRoomMessage(roomId, { role: "user", content: "third" });
const page = await request(appWithRoomReplies, "GET", `/api/chat/rooms/${roomId}/messages?before=${second.createdAt}`);
expect(page.status).toBe(200);
expect((page.body as any).messages.map((m: any) => m.id)).toContain(first.id);
const del1 = await request(appWithRoomReplies, "DELETE", `/api/chat/rooms/${roomId}/messages/${messageId}`);
expect(del1.status).toBe(200);
const del2 = await request(appWithRoomReplies, "DELETE", `/api/chat/rooms/${roomId}/messages/${messageId}`);
expect(del2.status).toBe(404);
const attachmentTarget = chatStore.addRoomMessage(roomId, { role: "user", content: "attach" });
const addAttachment = await request(
appWithRoomReplies,
"POST",
`/api/chat/rooms/${roomId}/messages/${attachmentTarget.id}/attachments`,
JSON.stringify({
id: "att-1",
filename: "a.txt",
originalName: "a.txt",
mimeType: "text/plain",
size: 1,
createdAt: new Date().toISOString(),
}),
{ "content-type": "application/json" },
);
expect(addAttachment.status).toBe(200);
expect((addAttachment.body as any).message.attachments).toHaveLength(1);
});
it("emits room SSE payloads for lifecycle/member/message events and cleans up listeners", () => {
const { req, res } = openSseConnection(chatStore);
const room = chatStore.createRoom({
name: "engineering",
projectId: "proj-1",
createdBy: "agent-owner",
memberAgentIds: ["agent-owner"],
});
const member = chatStore.addRoomMember(room.id, "agent-2", "member");
const message = chatStore.addRoomMessage(room.id, {
role: "user",
content: "hello room",
senderAgentId: null,
mentions: [],
});
const updatedRoom = chatStore.updateRoom(room.id, { description: "updated" });
expect(updatedRoom).toBeDefined();
chatStore.removeRoomMember(room.id, "agent-2");
const attachmentUpdatedMessage = chatStore.addRoomMessageAttachment(room.id, message.id, {
id: "att-1",
filename: "doc.txt",
originalName: "doc.txt",
mimeType: "text/plain",
size: 3,
createdAt: new Date().toISOString(),
});
chatStore.deleteRoomMessage(message.id);
chatStore.deleteRoom(room.id);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:created\ndata: ${JSON.stringify(room)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:updated\ndata: ${JSON.stringify(updatedRoom)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:deleted\ndata: ${JSON.stringify({ id: room.id })}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:member:added\ndata: ${JSON.stringify(member)}\n\n`);
expect(res.write).toHaveBeenCalledWith(
`event: chat:room:member:removed\ndata: ${JSON.stringify({ roomId: room.id, agentId: "agent-2" })}\n\n`,
);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:added\ndata: ${JSON.stringify(message)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:updated\ndata: ${JSON.stringify(attachmentUpdatedMessage)}\n\n`);
expect(res.write).toHaveBeenCalledWith(`event: chat:room:message:deleted\ndata: ${JSON.stringify({ id: message.id })}\n\n`);
req.emit("close");
expect(EventEmitter.listenerCount(chatStore, "chat:room:created")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:updated")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:deleted")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:member:added")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:member:removed")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:added")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:updated")).toBe(0);
expect(EventEmitter.listenerCount(chatStore, "chat:room:message:deleted")).toBe(0);
}); });
}); });

View File

@@ -1,17 +1,126 @@
import { describe, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatManager, __setCreateResolvedAgentSession, __resetChatState } from "../chat.js";
const mockChatStore = {
listRoomMembers: vi.fn(),
createSession: vi.fn(),
getRoom: vi.fn(),
addRoomMessage: vi.fn(),
};
const mockAgentStore = {
init: vi.fn(),
listAgents: vi.fn(),
};
describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => { describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
describe("Mention routing in rooms", () => { beforeEach(() => {
it.todo("direct mention in room routes targeted response from addressed room member"); vi.clearAllMocks();
it.todo("non-member mention behavior does not dispatch to out-of-room agents and surfaces explicit feedback"); __resetChatState();
mockChatStore.getRoom.mockReturnValue({ id: "room-1", name: "room-1" });
mockChatStore.addRoomMessage.mockImplementation((_roomId: string, input: any) => ({
id: `msg-${mockChatStore.addRoomMessage.mock.calls.length}`,
roomId: "room-1",
...input,
}));
}); });
describe("Hybrid dispatch behavior", () => { describe("resolveRoomResponders", () => {
it.todo("hybrid ambient response includes non-mentioned room members when room dispatch mode allows ambient participation"); it("partitions direct, ambient, and non-member mentions", () => {
it.todo("mention suppresses ambient on the addressed agent to avoid duplicate responses"); mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
{ roomId: "room-1", agentId: "agent-b", role: "member", addedAt: "2026-01-01" },
]);
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = (manager as any).resolveRoomResponders(
{ id: "chat-1", kind: "room", roomId: "room-1" },
[
{ agentId: "agent-b", agentName: "B" },
{ agentId: "agent-z", agentName: "Z" },
{ agentId: "agent-b", agentName: "B" },
],
[
{ id: "agent-a", name: "A" },
{ id: "agent-b", name: "B" },
{ id: "agent-z", name: "Z" },
],
);
expect(result.direct.map((agent: any) => agent.id)).toEqual(["agent-b"]);
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
expect(result.nonMemberMentions).toEqual([{ agentId: "agent-z", agentName: "Z" }]);
});
}); });
describe("Regression guard", () => { describe("sendRoomMessage", () => {
it.todo("direct-chat regression guard keeps legacy direct send path unchanged"); it("persists user and assistant messages for resolved responders", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-a", name: "Alpha", role: "executor" }]);
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Room reply" }],
},
},
provider: "test",
model: "test",
fallbackInfo: undefined,
} as any));
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = await manager.sendRoomMessage("room-1", "hello @Alpha");
expect(result.responders).toEqual(["agent-a"]);
const userWrite = mockChatStore.addRoomMessage.mock.calls[0]?.[1];
const assistantWrite = mockChatStore.addRoomMessage.mock.calls[1]?.[1];
expect(userWrite).toMatchObject({ role: "user", content: "hello @Alpha", mentions: ["agent-a"] });
expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" });
});
it("records non-member mentions and emits explanatory assistant note", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([
{ id: "agent-a", name: "Alpha", role: "executor" },
{ id: "agent-z", name: "Zeta", role: "executor" },
]);
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Room reply" }],
},
},
} as any));
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = await manager.sendRoomMessage("room-1", "hello @Alpha and @Zeta");
expect(result.responders).toEqual(["agent-a"]);
const writes = mockChatStore.addRoomMessage.mock.calls.map((call: any[]) => call[1]);
expect(writes[0]).toMatchObject({
role: "user",
metadata: {
nonMemberMentions: [{ agentId: "agent-z", agentName: "Zeta" }],
},
});
expect(writes[writes.length - 1]).toMatchObject({
role: "assistant",
senderAgentId: null,
content: expect.stringContaining("@Zeta"),
});
});
}); });
}); });