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:
@@ -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.
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user