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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user