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:
Fusion
2026-05-11 07:34:34 -07:00
committed by gsxdsm
parent 1257155a6f
commit 118e466104
7 changed files with 260 additions and 9 deletions

View File

@@ -748,8 +748,9 @@ When messaging tools are enabled for an agent, heartbeat runs check for unread m
Mailbox replies use `message.metadata.replyTo.messageId` as the stable reply link.
- `read_messages` includes each message ID in its human-readable output so agents can target a specific message.
- `send_message` supports `reply_to_message_id`; when provided, the sent message is stored with `metadata.replyTo.messageId`.
- `fn_read_messages` includes each message ID in its human-readable output so agents can target a specific message.
- When a message has `metadata.replyTo.messageId`, `fn_read_messages` now includes one-level reply-parent context inline (and in structured tool details) so heartbeat/mailbox runs can understand what the message is replying to without expanding full threads.
- `fn_send_message` supports `reply_to_message_id`; when provided, the sent message is stored with `metadata.replyTo.messageId`.
- Heartbeat prompts explicitly instruct agents to include `reply_to_message_id` when replying.
The dashboard mailbox UI also uses the same metadata contract when users click **Reply**, so user and agent replies share one threading model.

View File

@@ -114,6 +114,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
- After a successful room send, the room composer is cleared (matching direct-chat composer behavior) so stale text is not left in the input.
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
- Room responder prompt construction now includes a bounded recent room transcript (role, sender label, timestamp, content) plus an explicit latest-user-message marker, so replies stay thread-aware without unbounded prompt growth.
- The UI still avoids optimistic room echo; after `POST /api/chat/rooms/:id/messages`, it immediately re-fetches authoritative room messages to surface persisted user/assistant replies even if SSE delivery is delayed, and it continues to apply `chat:room:message:*` SSE updates for live fan-out.
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
- For backend details, see the [Chat Room REST API reference](./architecture.md#real-time-channels) and the [chat room storage schema (`chat_rooms`, `chat_room_members`, `chat_room_messages`)](./storage.md#chat-rooms-migration-70).

View File

@@ -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",

View File

@@ -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");
});
});
});

View File

@@ -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.
*

View File

@@ -729,6 +729,7 @@ function createMockMessageStore(overrides: Partial<MessageStore> = {}): MessageS
return {
sendMessage: vi.fn(),
getInbox: vi.fn().mockReturnValue([]),
getMessage: vi.fn().mockReturnValue(null),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
...overrides,
@@ -1142,7 +1143,66 @@ describe("createReadMessagesTool", () => {
expect((text as { text: string }).text).toContain("Messages (2)");
expect((text as { text: string }).text).toContain("[unread] [id: msg-1] [from: agent:agent-2] Hello there");
expect((text as { text: string }).text).toContain("[read] [id: msg-2] [from: user:user-1] Another message");
expect(result.details).toEqual({ messages });
expect(result.details).toEqual({ messages, threadContext: [] });
});
it("includes reply-parent context inline and in details when message links to a parent", async () => {
const child = createMessage({
id: "msg-child",
content: "Follow-up question",
metadata: { replyTo: { messageId: "msg-parent" } } as any,
});
const parent = createMessage({
id: "msg-parent",
fromId: "agent-9",
fromType: "agent",
content: "Parent message context",
});
vi.mocked(messageStore.getInbox).mockReturnValue([child]);
vi.mocked(messageStore.getMessage).mockReturnValue(parent);
const result = await executeTool(tool, {});
const text = result.content[0] as { type: string; text: string };
expect(text.text).toContain("↳ reply-to [id: msg-parent] [from: agent:agent-9] Parent message context");
expect(result.details).toEqual({
messages: [child],
threadContext: [{
messageId: "msg-child",
replyTo: {
parentMessageId: "msg-parent",
parentMessage: parent,
missingParent: false,
},
}],
});
});
it("surfaces missing-parent context without changing base inbox behavior", async () => {
const child = createMessage({
id: "msg-child",
content: "Follow-up question",
metadata: { replyTo: { messageId: "msg-missing" } } as any,
});
vi.mocked(messageStore.getInbox).mockReturnValue([child]);
vi.mocked(messageStore.getMessage).mockReturnValue(null);
const result = await executeTool(tool, {});
const text = result.content[0] as { type: string; text: string };
expect(text.text).toContain("↳ reply-to [id: msg-missing] (missing parent message)");
expect(result.details).toEqual({
messages: [child],
threadContext: [{
messageId: "msg-child",
replyTo: {
parentMessageId: "msg-missing",
parentMessage: null,
missingParent: true,
},
}],
});
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-1", "agent", { read: false, limit: 20 });
});
it("returns error when messageStore.getInbox throws", async () => {

View File

@@ -2096,6 +2096,43 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti
}
export function createReadMessagesTool(messageStore: MessageStore, agentId: string): ToolDefinition {
const REPLY_CONTEXT_CONTENT_MAX_CHARS = 400;
const trimReplyContent = (value: string): string => {
if (value.length <= REPLY_CONTEXT_CONTENT_MAX_CHARS) {
return value;
}
return `${value.slice(0, REPLY_CONTEXT_CONTENT_MAX_CHARS - 1)}`;
};
const resolveReplyContext = (msg: Message): {
parentMessageId: string;
parentMessage: Message | null;
missingParent: boolean;
} | null => {
const metadata = msg.metadata;
const parentMessageId = typeof metadata === "object"
&& metadata !== null
&& "replyTo" in metadata
&& typeof metadata.replyTo === "object"
&& metadata.replyTo !== null
&& "messageId" in metadata.replyTo
&& typeof metadata.replyTo.messageId === "string"
? metadata.replyTo.messageId
: null;
if (!parentMessageId) {
return null;
}
const parentMessage = messageStore.getMessage(parentMessageId);
return {
parentMessageId,
parentMessage,
missingParent: !parentMessage,
};
};
return {
name: "fn_read_messages",
label: "Read Messages",
@@ -2121,10 +2158,28 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
};
}
const lines = messages.map((msg: Message) => {
const timestamp = new Date(msg.createdAt).toLocaleString();
const readStatus = msg.read ? "[read] " : "[unread] ";
return `${readStatus}[id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
const messageEntries = messages.map((msg: Message) => {
const replyContext = resolveReplyContext(msg);
return {
message: msg,
replyContext,
};
});
const lines = messageEntries.map(({ message, replyContext }) => {
const timestamp = new Date(message.createdAt).toLocaleString();
const readStatus = message.read ? "[read] " : "[unread] ";
const baseLine = `${readStatus}[id: ${message.id}] [from: ${message.fromType}:${message.fromId}] ${message.content} (${timestamp})`;
if (!replyContext) {
return baseLine;
}
if (replyContext.parentMessage) {
const parent = replyContext.parentMessage;
return `${baseLine}\n ↳ reply-to [id: ${parent.id}] [from: ${parent.fromType}:${parent.fromId}] ${trimReplyContent(parent.content)}`;
}
return `${baseLine}\n ↳ reply-to [id: ${replyContext.parentMessageId}] (missing parent message)`;
});
return {
@@ -2132,7 +2187,15 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
type: "text" as const,
text: `Messages (${messages.length}):\n${lines.join("\n")}`,
}],
details: { messages },
details: {
messages,
threadContext: messageEntries
.filter((entry) => entry.replyContext)
.map((entry) => ({
messageId: entry.message.id,
replyTo: entry.replyContext,
})),
},
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);