feat(FN-4004): add bounded room thread and mailbox reply context to agents
Adds bounded room thread context and mailbox reply context to the chat system, implemented in `agent-tools.ts` with corresponding dashboard room handling in `chat.ts`; includes expanded test coverage for both modules and updated documentation in `agents.md`. Fusion-Task-Id: FN-4004
This commit is contained in:
@@ -64,6 +64,7 @@ const mockChatStore = {
|
||||
updateSession: vi.fn(),
|
||||
setCliSessionFile: vi.fn(),
|
||||
setInFlightGeneration: vi.fn(),
|
||||
getRoomMessages: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentStore = {
|
||||
@@ -115,6 +116,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
content: "",
|
||||
});
|
||||
mockChatStore.getMessages.mockReturnValue([]);
|
||||
mockChatStore.getRoomMessages.mockReturnValue([]);
|
||||
|
||||
mockAgentStore.init.mockResolvedValue(undefined);
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
@@ -1547,6 +1549,7 @@ describe("ChatManager diagnostics", () => {
|
||||
content: "",
|
||||
});
|
||||
mockChatStore.getMessages.mockReturnValue([]);
|
||||
mockChatStore.getRoomMessages.mockReturnValue([]);
|
||||
mockAgentStore.init.mockResolvedValue(undefined);
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
|
||||
@@ -6,6 +6,7 @@ const mockChatStore = {
|
||||
createSession: vi.fn(),
|
||||
getRoom: vi.fn(),
|
||||
addRoomMessage: vi.fn(),
|
||||
getRoomMessages: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentStore = {
|
||||
@@ -24,6 +25,7 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
roomId: "room-1",
|
||||
...input,
|
||||
}));
|
||||
mockChatStore.getRoomMessages.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe("resolveRoomResponders", () => {
|
||||
@@ -116,7 +118,8 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
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([
|
||||
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([
|
||||
@@ -152,5 +155,85 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
content: expect.stringContaining("@Zeta"),
|
||||
});
|
||||
});
|
||||
|
||||
it("includes bounded room transcript context in responder prompt", 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" }]);
|
||||
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor" });
|
||||
|
||||
const promptSpy = vi.fn().mockResolvedValue(undefined);
|
||||
__setCreateResolvedAgentSession(async () => ({
|
||||
session: {
|
||||
prompt: promptSpy,
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Room reply" }],
|
||||
},
|
||||
},
|
||||
} as any));
|
||||
|
||||
mockChatStore.getRoomMessages.mockReturnValue([
|
||||
{ id: "msg-older", role: "user", senderAgentId: null, content: "Older user context", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
{ id: "msg-assist", role: "assistant", senderAgentId: "agent-a", content: "Earlier assistant context", createdAt: "2026-01-01T00:00:01.000Z" },
|
||||
{ id: "msg-1", role: "user", senderAgentId: null, content: "hello @Alpha", createdAt: "2026-01-01T00:00:02.000Z" },
|
||||
]);
|
||||
|
||||
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
|
||||
await manager.sendRoomMessage("room-1", "hello @Alpha");
|
||||
|
||||
const prompt = promptSpy.mock.calls[0]?.[0] as string;
|
||||
expect(prompt).toContain("Room transcript (oldest to newest, bounded):");
|
||||
expect(prompt).toContain("Older user context");
|
||||
expect(prompt).toContain("Earlier assistant context");
|
||||
expect(prompt).toContain("[LATEST USER MESSAGE — ANSWER THIS]");
|
||||
expect(mockChatStore.getRoomMessages).toHaveBeenCalledWith("room-1", { limit: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("trims older room context entries from the prompt window", 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" }]);
|
||||
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor" });
|
||||
|
||||
const promptSpy = vi.fn().mockResolvedValue(undefined);
|
||||
__setCreateResolvedAgentSession(async () => ({
|
||||
session: {
|
||||
prompt: promptSpy,
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Room reply" }],
|
||||
},
|
||||
},
|
||||
} as any));
|
||||
|
||||
const history = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `msg-${index + 1}`,
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
senderAgentId: index % 2 === 0 ? null : "agent-a",
|
||||
content: `history-item-${index}`,
|
||||
createdAt: `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`,
|
||||
}));
|
||||
history[history.length - 1] = {
|
||||
...history[history.length - 1],
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
senderAgentId: null,
|
||||
content: "hello @Alpha",
|
||||
};
|
||||
mockChatStore.getRoomMessages.mockImplementation((_roomId: string, filter?: { limit?: number }) => {
|
||||
const limit = filter?.limit ?? history.length;
|
||||
return history.slice(-limit);
|
||||
});
|
||||
|
||||
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
|
||||
await manager.sendRoomMessage("room-1", "hello @Alpha");
|
||||
|
||||
const prompt = promptSpy.mock.calls[0]?.[0] as string;
|
||||
expect(prompt).toContain("history-item-28");
|
||||
expect(prompt).not.toContain("history-item-0");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,9 @@ 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;
|
||||
const ROOM_THREAD_CONTEXT_MAX_MESSAGES = 16;
|
||||
const ROOM_THREAD_CONTEXT_MAX_CHARS = 8_000;
|
||||
const ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS = 1_200;
|
||||
const IN_FLIGHT_PERSIST_DEBOUNCE_MS = 200;
|
||||
|
||||
function formatAttachmentSize(size: number): string {
|
||||
@@ -902,6 +905,7 @@ export class ChatManager {
|
||||
roomId,
|
||||
roomName: room.name,
|
||||
content: trimmedContent,
|
||||
latestUserMessageId: userMessage.id,
|
||||
mentions,
|
||||
responder,
|
||||
modelProvider,
|
||||
@@ -939,6 +943,7 @@ export class ChatManager {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
content: string;
|
||||
latestUserMessageId: string;
|
||||
mentions: ChatMention[];
|
||||
responder: Agent;
|
||||
modelProvider?: string;
|
||||
@@ -967,9 +972,13 @@ export class ChatManager {
|
||||
}
|
||||
systemPrompt = `${systemPrompt}\n\n${CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE}`;
|
||||
|
||||
const roomMessages = this.chatStore.getRoomMessages(input.roomId, { limit: ROOM_THREAD_CONTEXT_MAX_MESSAGES });
|
||||
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.",
|
||||
"Room transcript (oldest to newest, bounded):",
|
||||
this.formatRoomThreadContext(roomMessages, input.latestUserMessageId),
|
||||
"Latest user message to answer:",
|
||||
input.content,
|
||||
].join("\n\n");
|
||||
|
||||
@@ -1014,6 +1023,37 @@ export class ChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
private formatRoomThreadContext(
|
||||
messages: Array<{ id: string; role: "user" | "assistant" | "system"; content: string; createdAt: string; senderAgentId?: string | null }>,
|
||||
latestUserMessageId: string,
|
||||
): string {
|
||||
const trimmedFromTail: string[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const senderLabel = message.role === "user"
|
||||
? "User"
|
||||
: message.role === "system"
|
||||
? "System"
|
||||
: (message.senderAgentId ? `Agent ${message.senderAgentId}` : "Assistant");
|
||||
const content = message.content.length > ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS
|
||||
? `${message.content.slice(0, ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS - 1)}…`
|
||||
: message.content;
|
||||
const marker = message.id === latestUserMessageId ? " [LATEST USER MESSAGE — ANSWER THIS]" : "";
|
||||
const line = `- [${message.createdAt}] (${message.role}) ${senderLabel}: ${content}${marker}`;
|
||||
|
||||
if (trimmedFromTail.length > 0 && totalChars + line.length > ROOM_THREAD_CONTEXT_MAX_CHARS) {
|
||||
break;
|
||||
}
|
||||
|
||||
trimmedFromTail.push(line);
|
||||
totalChars += line.length;
|
||||
}
|
||||
|
||||
return trimmedFromTail.reverse().join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and stream AI response via SSE.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user