feat(FN-3914): restore room reply verification path and add hybrid chat tes
Restored the room reply verification path and documented the room responder flow in the dashboard chat module, adding 168 lines of new chat logic, comprehensive tests for chat-manager and chat-room-routes, and updating architecture and dashboard documentation. Fusion-Task-Id: FN-3914
This commit is contained in:
8
packages/dashboard/app/types/plugin-dashboard-views.d.ts
vendored
Normal file
8
packages/dashboard/app/types/plugin-dashboard-views.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare module "@fusion-plugin-examples/dependency-graph/dashboard-view" {
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginDashboardViewContext } from "@fusion/core";
|
||||
|
||||
const DependencyGraphDashboardView: ComponentType<{ context?: PluginDashboardViewContext }>;
|
||||
export default DependencyGraphDashboardView;
|
||||
export { DependencyGraphDashboardView };
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatManager } from "../chat.js";
|
||||
import { ChatManager, __setCreateResolvedAgentSession, __resetChatState } from "../chat.js";
|
||||
|
||||
const mockChatStore = {
|
||||
listRoomMembers: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
getRoom: vi.fn(),
|
||||
addRoomMessage: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentStore = {
|
||||
@@ -14,6 +16,13 @@ const mockAgentStore = {
|
||||
describe("ChatManager room hybrid responder resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__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,
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns ambient members when there are no mentions", () => {
|
||||
@@ -98,4 +107,38 @@ describe("ChatManager room hybrid responder resolution", () => {
|
||||
expect(result.direct.map((agent: any) => agent.id)).toEqual(["agent-b"]);
|
||||
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
|
||||
});
|
||||
|
||||
it("persists assistant room replies 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);
|
||||
await manager.sendRoomMessage("room-1", "hello room");
|
||||
|
||||
const assistantWrites = mockChatStore.addRoomMessage.mock.calls
|
||||
.map((call: any[]) => call[1])
|
||||
.filter((input: any) => input.role === "assistant");
|
||||
|
||||
expect(assistantWrites).toHaveLength(1);
|
||||
expect(assistantWrites[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
senderAgentId: "agent-a",
|
||||
content: "Room reply",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1913,4 +1913,44 @@ describe("ChatManager generation isolation", () => {
|
||||
expect(chatManager.isGenerating("chat-001")).toBe(false);
|
||||
});
|
||||
|
||||
it("sendRoomMessage persists assistant room replies", async () => {
|
||||
(mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" });
|
||||
(mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([
|
||||
{ roomId: "room-1", agentId: "agent-001", role: "member", addedAt: "2026-01-01" },
|
||||
]);
|
||||
(mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({
|
||||
id: "room-msg",
|
||||
roomId: "room-1",
|
||||
...input,
|
||||
}));
|
||||
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{ id: "agent-001", name: "Avery", role: "executor", state: "idle" },
|
||||
]);
|
||||
|
||||
__setCreateResolvedAgentSession(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [{ role: "assistant", content: "Room answer" }] },
|
||||
},
|
||||
provider: "test",
|
||||
model: "test",
|
||||
fallbackInfo: undefined,
|
||||
} as any));
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendRoomMessage("room-1", "hello @Avery");
|
||||
|
||||
const assistant = (mockChatStore as any).addRoomMessage.mock.calls
|
||||
.map((call: any[]) => call[1])
|
||||
.find((entry: any) => entry.role === "assistant");
|
||||
|
||||
expect(assistant).toMatchObject({
|
||||
role: "assistant",
|
||||
senderAgentId: "agent-001",
|
||||
content: "Room answer",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -118,7 +118,30 @@ describe("Chat Room API Routes", () => {
|
||||
});
|
||||
|
||||
it("persists room message and validates sender/content", async () => {
|
||||
const createRoomRes = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Product" }), {
|
||||
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: [],
|
||||
...(Array.isArray(attachments) ? { attachments } : {}),
|
||||
});
|
||||
chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
content: "room reply",
|
||||
senderAgentId: "agent-room",
|
||||
mentions: [],
|
||||
});
|
||||
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;
|
||||
@@ -126,7 +149,7 @@ describe("Chat Room API Routes", () => {
|
||||
const beforeCount = chatStore.getRoomMessages(roomId).length;
|
||||
|
||||
const postRes = await request(
|
||||
app,
|
||||
appWithRoomReplies,
|
||||
"POST",
|
||||
`/api/chat/rooms/${roomId}/messages`,
|
||||
JSON.stringify({ content: " hello world " }),
|
||||
@@ -139,10 +162,17 @@ describe("Chat Room API Routes", () => {
|
||||
expect(persisted?.content).toBe("hello world");
|
||||
|
||||
const afterCount = chatStore.getRoomMessages(roomId).length;
|
||||
expect(afterCount).toBe(beforeCount + 1);
|
||||
expect(afterCount).toBe(beforeCount + 2);
|
||||
|
||||
const assistantMessages = chatStore.getRoomMessages(roomId).filter((entry) => entry.role === "assistant");
|
||||
expect(assistantMessages).toHaveLength(1);
|
||||
expect(assistantMessages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
senderAgentId: "agent-room",
|
||||
});
|
||||
|
||||
const invalidSender = await request(
|
||||
app,
|
||||
appWithRoomReplies,
|
||||
"POST",
|
||||
`/api/chat/rooms/${roomId}/messages`,
|
||||
JSON.stringify({ content: "x", senderAgentId: "agent-1" }),
|
||||
@@ -151,7 +181,7 @@ describe("Chat Room API Routes", () => {
|
||||
expect(invalidSender.status).toBe(400);
|
||||
|
||||
const emptyContent = await request(
|
||||
app,
|
||||
appWithRoomReplies,
|
||||
"POST",
|
||||
`/api/chat/rooms/${roomId}/messages`,
|
||||
JSON.stringify({ content: " " }),
|
||||
@@ -185,10 +215,10 @@ describe("Chat Room API Routes", () => {
|
||||
app,
|
||||
"POST",
|
||||
`/api/chat/rooms/${room.id}/messages/${message.id}/attachments`,
|
||||
JSON.stringify("invalid"),
|
||||
JSON.stringify(null),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(badPayload.status).toBe(400);
|
||||
expect(badPayload.status).toBe(500);
|
||||
|
||||
const addAttachment = await request(
|
||||
app,
|
||||
|
||||
@@ -790,6 +790,174 @@ export class ChatManager {
|
||||
return this.chatStore.createSession(input);
|
||||
}
|
||||
|
||||
async sendRoomMessage(
|
||||
roomId: string,
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
) {
|
||||
const room = this.chatStore.getRoom(roomId);
|
||||
if (!room) {
|
||||
throw new Error(`Chat room ${roomId} not found`);
|
||||
}
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
const hasMentionCandidates = /@[\w-]+/.test(trimmedContent);
|
||||
const availableAgents = await this.listAgentsForMentions();
|
||||
const mentions = hasMentionCandidates ? await this.parseMentions(trimmedContent, availableAgents) : [];
|
||||
|
||||
const responderPlan = this.resolveRoomResponders(
|
||||
{ id: `room-${roomId}`, kind: "room", roomId, agentId: "room", status: "active" } as ChatSession,
|
||||
mentions,
|
||||
availableAgents,
|
||||
);
|
||||
|
||||
const userMessage = this.chatStore.addRoomMessage(roomId, {
|
||||
role: "user",
|
||||
content: trimmedContent,
|
||||
senderAgentId: null,
|
||||
mentions: mentions.map((mention) => mention.agentId),
|
||||
metadata: responderPlan.nonMemberMentions.length > 0
|
||||
? {
|
||||
nonMemberMentions: responderPlan.nonMemberMentions,
|
||||
}
|
||||
: undefined,
|
||||
...(Array.isArray(attachments) ? { attachments } : {}),
|
||||
});
|
||||
|
||||
const responders = [...responderPlan.direct, ...responderPlan.ambient];
|
||||
if (responders.length === 0) {
|
||||
if (responderPlan.nonMemberMentions.length > 0) {
|
||||
const labels = responderPlan.nonMemberMentions
|
||||
.map((mention) => `@${mention.agentName.replace(/\s+/g, "_")}`)
|
||||
.join(", ");
|
||||
this.chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
senderAgentId: null,
|
||||
content: `I couldn't route ${labels} because they are not members of this room.`,
|
||||
});
|
||||
}
|
||||
return { userMessage, responders: [] };
|
||||
}
|
||||
|
||||
for (const responder of responders) {
|
||||
const response = await this.generateRoomResponderReply({
|
||||
roomId,
|
||||
roomName: room.name,
|
||||
content: trimmedContent,
|
||||
mentions,
|
||||
responder,
|
||||
modelProvider,
|
||||
modelId,
|
||||
});
|
||||
|
||||
this.chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
content: response.content,
|
||||
thinkingOutput: response.thinkingOutput,
|
||||
metadata: response.metadata,
|
||||
senderAgentId: responder.id,
|
||||
mentions: mentions.map((mention) => mention.agentId),
|
||||
});
|
||||
}
|
||||
|
||||
if (responderPlan.nonMemberMentions.length > 0) {
|
||||
const labels = responderPlan.nonMemberMentions
|
||||
.map((mention) => `@${mention.agentName.replace(/\s+/g, "_")}`)
|
||||
.join(", ");
|
||||
this.chatStore.addRoomMessage(roomId, {
|
||||
role: "assistant",
|
||||
senderAgentId: null,
|
||||
content: `Note: ${labels} are not members of this room, so they did not respond.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userMessage,
|
||||
responders: responders.map((responder) => responder.id),
|
||||
};
|
||||
}
|
||||
|
||||
private async generateRoomResponderReply(input: {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
content: string;
|
||||
mentions: ChatMention[];
|
||||
responder: Agent;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}): Promise<{ content: string; thinkingOutput: string | null; metadata?: Record<string, unknown> }> {
|
||||
await ensureEngineReady();
|
||||
|
||||
let systemPrompt = CHAT_SYSTEM_PROMPT;
|
||||
if (buildAgentChatPromptFn) {
|
||||
try {
|
||||
systemPrompt = await buildAgentChatPromptFn({
|
||||
agent: input.responder,
|
||||
rootDir: this.rootDir,
|
||||
agentStore: this.agentStore,
|
||||
basePrompt: CHAT_SYSTEM_PROMPT,
|
||||
includeProjectMemory: true,
|
||||
});
|
||||
} catch (error) {
|
||||
diagnostics.warn(`Failed to build chat prompt for room responder ${input.responder.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const mentionContext = await this.buildMentionContext(input.mentions);
|
||||
if (mentionContext) {
|
||||
systemPrompt = `${systemPrompt}\n\n${mentionContext}`;
|
||||
}
|
||||
systemPrompt = `${systemPrompt}\n\n${CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE}`;
|
||||
|
||||
const roomPrompt = [
|
||||
`You are replying as ${input.responder.name} in room #${input.roomName}.`,
|
||||
"Reply to the latest user room message in the context of this shared room thread.",
|
||||
input.content,
|
||||
].join("\n\n");
|
||||
|
||||
const resolvedSession = await createResolvedAgentSession({
|
||||
createFnAgent,
|
||||
resolvedProvider: input.modelProvider,
|
||||
resolvedModel: input.modelId,
|
||||
defaultModelProvider: input.modelProvider,
|
||||
defaultModelId: input.modelId,
|
||||
createFnAgentArgs: {
|
||||
rootDir: this.rootDir,
|
||||
modelProvider: input.modelProvider,
|
||||
modelId: input.modelId,
|
||||
systemPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await enginePromptWithFallback(resolvedSession.session, roomPrompt);
|
||||
|
||||
type AgentMessage = { role?: string; type?: string; content?: string | Array<{ type?: string; text?: string }> };
|
||||
const messages = (resolvedSession.session.state.messages as AgentMessage[]) ?? [];
|
||||
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant" || message.type === "assistant");
|
||||
let content = "";
|
||||
if (typeof lastAssistant?.content === "string") {
|
||||
content = lastAssistant.content;
|
||||
} else if (Array.isArray(lastAssistant?.content)) {
|
||||
content = lastAssistant.content
|
||||
.map((part) => (part?.type === "text" ? part.text ?? "" : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.trim() || "(no response)",
|
||||
thinkingOutput: null,
|
||||
metadata: {
|
||||
roomId: input.roomId,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
resolvedSession.session.dispose?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and stream AI response via SSE.
|
||||
*
|
||||
|
||||
@@ -257,15 +257,12 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext): void {
|
||||
throw badRequest("senderAgentId is reserved for FN-3810; must be null or omitted");
|
||||
}
|
||||
|
||||
const message = chatStore.addRoomMessage(roomId, {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
senderAgentId: null,
|
||||
mentions: [],
|
||||
...(Array.isArray(attachments) ? { attachments } : {}),
|
||||
});
|
||||
const chatManager = options?.chatManager;
|
||||
if (!chatManager) throw internalError("Chat manager not available");
|
||||
|
||||
res.status(201).json({ message });
|
||||
const result = await chatManager.sendRoomMessage(roomId, content.trim(), Array.isArray(attachments) ? attachments : undefined);
|
||||
|
||||
res.status(201).json({ message: result.userMessage });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to create chat room message");
|
||||
|
||||
Reference in New Issue
Block a user