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 7af1317f34
commit 43b1f6e772
20 changed files with 3972 additions and 17 deletions

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mock MessageStore ────────────────────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetInbox = vi.fn();
const mockGetOutbox = vi.fn();
const mockGetMailbox = vi.fn();
const mockGetMessage = vi.fn();
const mockSendMessage = vi.fn();
const mockMarkAsRead = vi.fn();
const mockDeleteMessage = vi.fn();
vi.mock("@fusion/core", () => ({
MessageStore: vi.fn().mockImplementation(() => ({
init: mockInit,
getInbox: mockGetInbox,
getOutbox: mockGetOutbox,
getMailbox: mockGetMailbox,
getMessage: mockGetMessage,
sendMessage: mockSendMessage,
markAsRead: mockMarkAsRead,
deleteMessage: mockDeleteMessage,
})),
}));
// ── Mock project-context ─────────────────────────────────────────────
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "test-project",
projectPath: "/tmp/test-project",
projectName: "test-project",
isRegistered: true,
store: {},
}),
}));
// ── Spies ────────────────────────────────────────────────────────────
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as any);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// ── Import after mocks ───────────────────────────────────────────────
import {
runMessageInbox,
runMessageOutbox,
runMessageSend,
runMessageRead,
runMessageDelete,
runAgentMailbox,
} from "../message.js";
// ── Test Data ─────────────────────────────────────────────────────────
const mockMessage = {
id: "msg-001",
fromId: "agent-001",
fromType: "agent" as const,
toId: "cli",
toType: "user" as const,
content: "Hello from the agent",
type: "agent-to-user" as const,
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockReadMessage = {
...mockMessage,
id: "msg-002",
read: true,
content: "This is read",
};
// ── Tests ────────────────────────────────────────────────────────────
describe("runMessageInbox", () => {
beforeEach(() => {
mockGetMailbox.mockResolvedValue({ unreadCount: 2, ownerId: "cli", ownerType: "user" });
mockGetInbox.mockResolvedValue([mockMessage, mockReadMessage]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should list inbox messages with unread count", async () => {
await runMessageInbox();
expect(mockGetInbox).toHaveBeenCalledWith("cli", "user", { limit: 20 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Inbox"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2 unread"));
});
it("should show 'No messages' when inbox is empty", async () => {
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "cli", ownerType: "user" });
mockGetInbox.mockResolvedValue([]);
await runMessageInbox();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No messages"));
});
it("should show unread marker for unread messages", async () => {
await runMessageInbox();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("●"));
});
it("should truncate long messages", async () => {
mockGetInbox.mockResolvedValue([{
...mockMessage,
content: "A".repeat(200),
}]);
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "cli", ownerType: "user" });
await runMessageInbox();
// Should truncate to 80 chars + "…"
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("…"));
});
});
describe("runMessageOutbox", () => {
beforeEach(() => {
mockGetOutbox.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should list sent messages", async () => {
const sentMessage = {
...mockMessage,
fromId: "cli",
fromType: "user" as const,
toId: "agent-001",
toType: "agent" as const,
type: "user-to-agent" as const,
};
mockGetOutbox.mockResolvedValue([sentMessage]);
await runMessageOutbox();
expect(mockGetOutbox).toHaveBeenCalledWith("cli", "user", { limit: 20 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Outbox"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
});
it("should show 'No sent messages' when outbox is empty", async () => {
mockGetOutbox.mockResolvedValue([]);
await runMessageOutbox();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No sent messages"));
});
});
describe("runMessageSend", () => {
beforeEach(() => {
mockSendMessage.mockResolvedValue(mockMessage);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should send a message to an agent", async () => {
await runMessageSend("agent-001", "Hello agent!");
expect(mockSendMessage).toHaveBeenCalledWith(
expect.objectContaining({
fromId: "cli",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Hello agent!",
type: "user-to-agent",
}),
);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Message sent"));
});
it("should show the message ID after sending", async () => {
await runMessageSend("agent-001", "Test message");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("msg-001"));
});
});
describe("runMessageRead", () => {
beforeEach(() => {
mockGetMessage.mockResolvedValue(mockMessage);
mockMarkAsRead.mockResolvedValue({ ...mockMessage, read: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it("should display a message and mark as read", async () => {
await runMessageRead("msg-001");
expect(mockGetMessage).toHaveBeenCalledWith("msg-001");
expect(mockMarkAsRead).toHaveBeenCalledWith("msg-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("msg-001"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Hello from the agent"));
});
it("should show message details", async () => {
await runMessageRead("msg-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("agent-to-user"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
});
it("should not mark as read if already read", async () => {
mockGetMessage.mockResolvedValue(mockReadMessage);
await runMessageRead("msg-002");
expect(mockMarkAsRead).not.toHaveBeenCalled();
});
it("should exit with error for missing message", async () => {
mockGetMessage.mockResolvedValue(null);
await expect(runMessageRead("msg-nonexistent")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runMessageDelete", () => {
beforeEach(() => {
mockDeleteMessage.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should delete a message", async () => {
await runMessageDelete("msg-001");
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Message msg-001 deleted"));
});
});
describe("runAgentMailbox", () => {
beforeEach(() => {
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "agent-001", ownerType: "agent" });
mockGetInbox.mockResolvedValue([mockMessage]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should show agent mailbox with unread count", async () => {
await runAgentMailbox("agent-001");
expect(mockGetMailbox).toHaveBeenCalledWith("agent-001", "agent");
expect(mockGetInbox).toHaveBeenCalledWith("agent-001", "agent", { limit: 20 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent Mailbox: agent-001"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("1 unread"));
});
it("should show 'No messages' when agent mailbox is empty", async () => {
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "agent-001", ownerType: "agent" });
mockGetInbox.mockResolvedValue([]);
await runAgentMailbox("agent-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No messages"));
});
it("should show messages with from label", async () => {
await runAgentMailbox("agent-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agent agent-001"));
});
});