feat(FN-3810): define room responder policy helper for hybrid room chat
Defines a room responder policy helper in `@fusion/core` with corresponding types, implements supporting logic in the dashboard chat module, and adds comprehensive tests for the hybrid room manager scenario (FN-3810 step 2). Fusion-Task-Id: FN-3810
This commit is contained in:
@@ -21,6 +21,12 @@ export type ChatMessageRole = "user" | "assistant" | "system";
|
||||
*/
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
/** Session routing kind; legacy sessions default to direct */
|
||||
kind?: "direct" | "room";
|
||||
/** Room ID when kind is room */
|
||||
roomId?: string | null;
|
||||
/** Optional room name for prompt context */
|
||||
roomName?: string | null;
|
||||
/** ID of the agent participating in this session */
|
||||
agentId: string;
|
||||
/** Human-readable title for the session (optional, can be auto-generated) */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatManager } from "../chat.js";
|
||||
|
||||
const mockChatStore = {
|
||||
listRoomMembers: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentStore = {
|
||||
init: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ChatManager room hybrid responder resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns ambient members when there are no mentions", () => {
|
||||
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" },
|
||||
[],
|
||||
[
|
||||
{ id: "agent-a", name: "A" },
|
||||
{ id: "agent-b", name: "B" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.direct).toEqual([]);
|
||||
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
it("routes mentioned member to direct and others to ambient", () => {
|
||||
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" }],
|
||||
[
|
||||
{ id: "agent-a", name: "A" },
|
||||
{ id: "agent-b", name: "B" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.direct.map((agent: any) => agent.id)).toEqual(["agent-b"]);
|
||||
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
|
||||
});
|
||||
|
||||
it("ignores non-member mentions for direct dispatch", () => {
|
||||
mockChatStore.listRoomMembers.mockReturnValue([
|
||||
{ roomId: "room-1", agentId: "agent-a", 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-z", agentName: "Z" }],
|
||||
[
|
||||
{ id: "agent-a", name: "A" },
|
||||
{ id: "agent-z", name: "Z" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.direct).toEqual([]);
|
||||
expect(result.nonMemberMentions).toEqual([{ agentId: "agent-z", agentName: "Z" }]);
|
||||
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
|
||||
});
|
||||
|
||||
it("dedupes duplicate mentions", () => {
|
||||
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-b", agentName: "B" },
|
||||
],
|
||||
[
|
||||
{ id: "agent-a", name: "A" },
|
||||
{ id: "agent-b", name: "B" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.direct.map((agent: any) => agent.id)).toEqual(["agent-b"]);
|
||||
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,7 @@ const MAX_MESSAGES_PER_IP_PER_MINUTE = 30;
|
||||
|
||||
/** Maximum file size for # mentions (50KB). Files larger than this are skipped. */
|
||||
const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
|
||||
const ROOM_AMBIENT_MAX_RESPONDERS = 5;
|
||||
|
||||
function formatAttachmentSize(size: number): string {
|
||||
if (size < 1024) return `${size}B`;
|
||||
@@ -734,6 +735,53 @@ export class ChatManager {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private resolveRoomResponders(
|
||||
session: ChatSession,
|
||||
mentions: ChatMention[],
|
||||
availableAgents: Agent[],
|
||||
): { direct: Agent[]; ambient: Agent[]; nonMemberMentions: ChatMention[] } {
|
||||
if (session.kind !== "room" || !session.roomId) {
|
||||
return { direct: [], ambient: [], nonMemberMentions: [] };
|
||||
}
|
||||
|
||||
const roomMembers = this.chatStore.listRoomMembers(session.roomId);
|
||||
const memberIds = new Set(roomMembers.map((member) => member.agentId));
|
||||
const agentsById = new Map(availableAgents.map((agent) => [agent.id, agent]));
|
||||
|
||||
const direct: Agent[] = [];
|
||||
const seenDirect = new Set<string>();
|
||||
const nonMemberMentions: ChatMention[] = [];
|
||||
|
||||
for (const mention of mentions) {
|
||||
if (!memberIds.has(mention.agentId)) {
|
||||
nonMemberMentions.push(mention);
|
||||
continue;
|
||||
}
|
||||
if (seenDirect.has(mention.agentId)) {
|
||||
continue;
|
||||
}
|
||||
const agent = agentsById.get(mention.agentId);
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
direct.push(agent);
|
||||
seenDirect.add(mention.agentId);
|
||||
}
|
||||
|
||||
const ambientCandidates = roomMembers
|
||||
.map((member) => agentsById.get(member.agentId))
|
||||
.filter((agent): agent is Agent => Boolean(agent) && !seenDirect.has(agent.id));
|
||||
|
||||
const ambient = ambientCandidates.slice(0, ROOM_AMBIENT_MAX_RESPONDERS);
|
||||
if (ambientCandidates.length > ROOM_AMBIENT_MAX_RESPONDERS) {
|
||||
diagnostics.warn(
|
||||
`Room ${session.roomId} ambient responders capped at ${ROOM_AMBIENT_MAX_RESPONDERS} (from ${ambientCandidates.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { direct, ambient, nonMemberMentions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user