feat(FN-989): add inter-agent messaging system with mailbox UI and CLI commands

- Add Message types (Message, MessageThread, MessageRecipient) and exports to @fusion/core
- Create MessageStore with full CRUD: send, read, delete, inbox, threads, and search
- Add messages table migration (schema v12) with SQLite full-text search support
- Add REST API routes for messaging (CRUD, search, broadcast, unread count)
- Add frontend API client functions for all messaging endpoints
- Build MailboxModal and MessageComposer dashboard components with header integration
- Add CLI message commands (inbox, send, read, delete) with rich output formatting
- Add comprehensive test coverage for MessageStore, CLI commands, and UI components
- Update documentation (CLI STANDALONE.md, dashboard README) with messaging usage
This commit is contained in:
gsxdsm
2026-04-07 11:21:59 -07:00
parent a22676b1d1
commit 7cc4ee5ebb
20 changed files with 3972 additions and 17 deletions

View File

@@ -0,0 +1,555 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { MessageStore } from "./message-store.js";
import type { Message, Mailbox } from "./types.js";
describe("MessageStore", () => {
let store: MessageStore;
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-msg-test-"));
store = new MessageStore({ rootDir: tempDir });
await store.init();
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("init()", () => {
it("creates messages directory and index file", async () => {
const { existsSync } = await import("node:fs");
expect(existsSync(join(tempDir, "messages"))).toBe(true);
expect(existsSync(join(tempDir, "messages", "index.json"))).toBe(true);
});
it("is idempotent — calling init twice does not throw", async () => {
await store.init();
await store.init();
});
});
describe("sendMessage() and getMessage()", () => {
it("creates and retrieves a message", async () => {
const message = await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello agent!",
type: "user-to-agent",
});
expect(message.id).toBeTruthy();
expect(message.id).toMatch(/^msg-/);
expect(message.fromId).toBe("user-1");
expect(message.fromType).toBe("user");
expect(message.toId).toBe("agent-1");
expect(message.toType).toBe("agent");
expect(message.content).toBe("Hello agent!");
expect(message.type).toBe("user-to-agent");
expect(message.read).toBe(false);
expect(message.createdAt).toBeTruthy();
expect(message.updatedAt).toBeTruthy();
const retrieved = await store.getMessage(message.id);
expect(retrieved).toEqual(message);
});
it("auto-fills sender as system when not provided", async () => {
const message = await store.sendMessage({
toId: "user-1",
toType: "user",
content: "System notification",
type: "system",
});
expect(message.fromId).toBe("system");
expect(message.fromType).toBe("system");
});
it("stores metadata when provided", async () => {
const message = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Task completed",
type: "agent-to-user",
metadata: { taskId: "FN-001", priority: "high" },
});
expect(message.metadata).toEqual({ taskId: "FN-001", priority: "high" });
});
it("returns null for non-existent message", async () => {
const result = await store.getMessage("msg-nonexistent");
expect(result).toBeNull();
});
});
describe("getInbox()", () => {
it("returns inbox messages for a participant", async () => {
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Message 1",
type: "agent-to-user",
});
await store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Message 2",
type: "agent-to-user",
});
const inbox = await store.getInbox("user-1", "user");
expect(inbox).toHaveLength(2);
// Newest first
expect(inbox[0].content).toBe("Message 2");
expect(inbox[1].content).toBe("Message 1");
});
it("returns empty array for participant with no messages", async () => {
const inbox = await store.getInbox("user-99", "user");
expect(inbox).toEqual([]);
});
it("filters by read status", async () => {
const msg1 = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread",
type: "agent-to-user",
});
const msg2 = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Will be read",
type: "agent-to-user",
});
await store.markAsRead(msg2.id);
const unreadOnly = await store.getInbox("user-1", "user", { read: false });
expect(unreadOnly).toHaveLength(1);
expect(unreadOnly[0].id).toBe(msg1.id);
const readOnly = await store.getInbox("user-1", "user", { read: true });
expect(readOnly).toHaveLength(1);
expect(readOnly[0].id).toBe(msg2.id);
});
it("applies pagination (limit/offset)", async () => {
for (let i = 0; i < 5; i++) {
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: `Message ${i}`,
type: "agent-to-user",
});
}
const page1 = await store.getInbox("user-1", "user", { limit: 2, offset: 0 });
expect(page1).toHaveLength(2);
const page2 = await store.getInbox("user-1", "user", { limit: 2, offset: 2 });
expect(page2).toHaveLength(2);
// No overlap
expect(page1[0].id).not.toBe(page2[0].id);
});
it("filters by message type", async () => {
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Agent message",
type: "agent-to-user",
});
await store.sendMessage({
fromId: "system",
fromType: "system",
toId: "user-1",
toType: "user",
content: "System message",
type: "system",
});
const agentOnly = await store.getInbox("user-1", "user", { type: "agent-to-user" });
expect(agentOnly).toHaveLength(1);
expect(agentOnly[0].type).toBe("agent-to-user");
const systemOnly = await store.getInbox("user-1", "user", { type: "system" });
expect(systemOnly).toHaveLength(1);
expect(systemOnly[0].type).toBe("system");
});
});
describe("getOutbox()", () => {
it("returns sent messages for a participant", async () => {
await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Outgoing 1",
type: "user-to-agent",
});
await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-2",
toType: "agent",
content: "Outgoing 2",
type: "user-to-agent",
});
const outbox = await store.getOutbox("user-1", "user");
expect(outbox).toHaveLength(2);
expect(outbox[0].content).toBe("Outgoing 2");
expect(outbox[1].content).toBe("Outgoing 1");
});
it("returns empty array when no messages sent", async () => {
const outbox = await store.getOutbox("user-99", "user");
expect(outbox).toEqual([]);
});
});
describe("markAsRead()", () => {
it("marks a message as read", async () => {
const message = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Read me",
type: "agent-to-user",
});
expect(message.read).toBe(false);
const updated = await store.markAsRead(message.id);
expect(updated.read).toBe(true);
const retrieved = await store.getMessage(message.id);
expect(retrieved!.read).toBe(true);
});
it("is idempotent for already-read messages", async () => {
const message = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Already read",
type: "agent-to-user",
});
await store.markAsRead(message.id);
const updated = await store.markAsRead(message.id);
expect(updated.read).toBe(true);
});
it("throws for non-existent message", async () => {
await expect(store.markAsRead("msg-nonexistent")).rejects.toThrow("not found");
});
});
describe("markAllAsRead()", () => {
it("marks all unread messages as read", async () => {
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Msg 1",
type: "agent-to-user",
});
await store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Msg 2",
type: "agent-to-user",
});
const count = await store.markAllAsRead("user-1", "user");
expect(count).toBe(2);
const inbox = await store.getInbox("user-1", "user");
expect(inbox.every((m) => m.read)).toBe(true);
});
it("returns 0 when no unread messages", async () => {
const count = await store.markAllAsRead("user-99", "user");
expect(count).toBe(0);
});
});
describe("deleteMessage()", () => {
it("deletes a message", async () => {
const message = await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
await store.deleteMessage(message.id);
const retrieved = await store.getMessage(message.id);
expect(retrieved).toBeNull();
});
it("removes message from inbox index", async () => {
const message = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Delete me",
type: "agent-to-user",
});
await store.deleteMessage(message.id);
const inbox = await store.getInbox("user-1", "user");
expect(inbox).toHaveLength(0);
});
it("removes message from outbox index", async () => {
const message = await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
await store.deleteMessage(message.id);
const outbox = await store.getOutbox("user-1", "user");
expect(outbox).toHaveLength(0);
});
it("throws for non-existent message", async () => {
await expect(store.deleteMessage("msg-nonexistent")).rejects.toThrow("not found");
});
});
describe("getConversation()", () => {
it("returns all messages between two participants", async () => {
// user-1 sends to agent-1
await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
// agent-1 replies to user-1
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Hi there",
type: "agent-to-user",
});
// Unrelated message
await store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unrelated",
type: "agent-to-user",
});
const conversation = await store.getConversation(
{ id: "user-1", type: "user" },
{ id: "agent-1", type: "agent" },
);
expect(conversation).toHaveLength(2);
// Oldest first
expect(conversation[0].content).toBe("Hello");
expect(conversation[1].content).toBe("Hi there");
});
it("returns empty array when no conversation exists", async () => {
const conversation = await store.getConversation(
{ id: "user-1", type: "user" },
{ id: "agent-99", type: "agent" },
);
expect(conversation).toEqual([]);
});
});
describe("getMailbox()", () => {
it("returns mailbox summary with unread count", async () => {
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread 1",
type: "agent-to-user",
});
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread 2",
type: "agent-to-user",
});
const mailbox = await store.getMailbox("user-1", "user");
expect(mailbox.ownerId).toBe("user-1");
expect(mailbox.ownerType).toBe("user");
expect(mailbox.unreadCount).toBe(2);
expect(mailbox.lastMessage).toBeTruthy();
expect(mailbox.lastMessage!.content).toBe("Unread 2");
});
it("returns 0 unread when no messages", async () => {
const mailbox = await store.getMailbox("user-99", "user");
expect(mailbox.unreadCount).toBe(0);
expect(mailbox.lastMessage).toBeUndefined();
});
it("counts only unread messages", async () => {
const msg1 = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Will be read",
type: "agent-to-user",
});
await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Stays unread",
type: "agent-to-user",
});
await store.markAsRead(msg1.id);
const mailbox = await store.getMailbox("user-1", "user");
expect(mailbox.unreadCount).toBe(1);
});
});
describe("events", () => {
it("emits message:sent event on send", async () => {
const events: Message[] = [];
store.on("message:sent", (msg) => events.push(msg));
await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
expect(events).toHaveLength(1);
expect(events[0].content).toBe("Hello");
});
it("emits message:received event on send", async () => {
const events: Message[] = [];
store.on("message:received", (msg) => events.push(msg));
await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
expect(events).toHaveLength(1);
});
it("emits message:read event on mark as read", async () => {
const events: Message[] = [];
store.on("message:read", (msg) => events.push(msg));
const message = await store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Read me",
type: "agent-to-user",
});
await store.markAsRead(message.id);
expect(events).toHaveLength(1);
expect(events[0].read).toBe(true);
});
it("emits message:deleted event on delete", async () => {
const events: string[] = [];
store.on("message:deleted", (id) => events.push(id));
const message = await store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
await store.deleteMessage(message.id);
expect(events).toHaveLength(1);
expect(events[0]).toBe(message.id);
});
});
});