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:
@@ -108,6 +108,17 @@ fn task import owner/repo --limit 10 --labels "bug,enhancement"
|
||||
```bash
|
||||
fn agent stop <agent-id> # Stop (pause) a running agent
|
||||
fn agent start <agent-id> # Start (resume) a stopped agent
|
||||
fn agent mailbox <agent-id> # View an agent's mailbox
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
```bash
|
||||
fn message inbox # List inbox messages
|
||||
fn message outbox # List sent messages
|
||||
fn message send <agent-id> <msg> # Send a message to an agent
|
||||
fn message read <id> # Read a specific message
|
||||
fn message delete <id> # Delete a message
|
||||
```
|
||||
|
||||
### Typical workflow
|
||||
|
||||
@@ -49,6 +49,7 @@ const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runM
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
const { runInit } = await import("./commands/init.js");
|
||||
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
||||
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -109,6 +110,12 @@ Usage:
|
||||
fn git fetch [remote] Fetch from remote (default: origin)
|
||||
fn agent stop <id> Stop a running agent (pause execution)
|
||||
fn agent start <id> Start a stopped agent (resume execution)
|
||||
fn agent mailbox <id> View an agent's mailbox
|
||||
fn message inbox List inbox messages
|
||||
fn message outbox List sent messages
|
||||
fn message send <agent-id> <msg> Send a message to an agent
|
||||
fn message read <id> Read a specific message
|
||||
fn message delete <id> Delete a message
|
||||
fn backup --create Create a database backup immediately
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> Restore database from a backup file
|
||||
@@ -746,9 +753,56 @@ async function main() {
|
||||
await runAgentStart(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "mailbox": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn agent mailbox <id>"); process.exit(1); }
|
||||
await runAgentMailbox(id, projectName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: agent ${subcommand || ""}`);
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id>");
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "inbox": {
|
||||
await runMessageInbox(projectName);
|
||||
break;
|
||||
}
|
||||
case "outbox": {
|
||||
await runMessageOutbox(projectName);
|
||||
break;
|
||||
}
|
||||
case "send": {
|
||||
const toId = args[2];
|
||||
const content = args[3];
|
||||
if (!toId || !content) {
|
||||
console.error("Usage: fn message send <agent-id> <content>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMessageSend(toId, content, projectName);
|
||||
break;
|
||||
}
|
||||
case "read": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn message read <id>"); process.exit(1); }
|
||||
await runMessageRead(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn message delete <id>"); process.exit(1); }
|
||||
await runMessageDelete(id, projectName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: message ${subcommand || ""}`);
|
||||
console.log("Try: fn message inbox | fn message outbox | fn message send | fn message read | fn message delete");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
291
packages/cli/src/commands/__tests__/message.test.ts
Normal file
291
packages/cli/src/commands/__tests__/message.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
209
packages/cli/src/commands/message.ts
Normal file
209
packages/cli/src/commands/message.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { MessageStore } from "@fusion/core";
|
||||
import type { ParticipantType } from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Get the project path for message operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*/
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initialized MessageStore for the given project.
|
||||
*/
|
||||
async function createMessageStore(projectName?: string): Promise<MessageStore> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const msgStore = new MessageStore({ rootDir: projectPath + "/.fusion" });
|
||||
await msgStore.init();
|
||||
return msgStore;
|
||||
}
|
||||
|
||||
/** User ID for CLI-originated messages */
|
||||
const CLI_USER_ID = "cli";
|
||||
|
||||
/**
|
||||
* List inbox messages.
|
||||
*/
|
||||
export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = await store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = msg.fromType === "agent" ? `Agent ${msg.fromId}` : msg.fromId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sent messages.
|
||||
*/
|
||||
export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const messages = await store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(" 📤 Outbox");
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No sent messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const toLabel = msg.toType === "agent" ? `Agent ${msg.toId}` : msg.toId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` To: ${toLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an agent.
|
||||
*/
|
||||
export async function runMessageSend(toId: string, content: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
toType: "agent",
|
||||
content,
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message sent: ${message.id}`);
|
||||
console.log(` To: Agent ${toId}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and display a specific message.
|
||||
*/
|
||||
export async function runMessageRead(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.getMessage(id);
|
||||
|
||||
if (!message) {
|
||||
console.error(`Message ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (!message.read) {
|
||||
await store.markAsRead(id);
|
||||
}
|
||||
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
const toLabel = formatParticipant(message.toId, message.toType);
|
||||
const timeStr = new Date(message.createdAt).toLocaleString();
|
||||
|
||||
console.log();
|
||||
console.log(` Message: ${message.id}`);
|
||||
console.log(` Type: ${message.type}`);
|
||||
console.log(` From: ${fromLabel}`);
|
||||
console.log(` To: ${toLabel}`);
|
||||
console.log(` Time: ${timeStr}`);
|
||||
console.log();
|
||||
console.log(` ${message.content}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*/
|
||||
export async function runMessageDelete(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
await store.deleteMessage(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message ${id} deleted`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* View an agent's mailbox.
|
||||
*/
|
||||
export async function runAgentMailbox(agentId: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(agentId, "agent");
|
||||
const messages = await store.getInbox(agentId, "agent", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 🤖 Agent Mailbox: ${agentId} (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = formatParticipant(msg.fromId, msg.fromType);
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}From: ${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatParticipant(id: string, type: ParticipantType): string {
|
||||
switch (type) {
|
||||
case "agent": return `Agent ${id}`;
|
||||
case "user": return id === "cli" ? "You (CLI)" : id === "dashboard" ? "You (Dashboard)" : `User ${id}`;
|
||||
case "system": return "System";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -70,6 +70,7 @@ describe("Database", () => {
|
||||
expect(tableNames).toContain("slices");
|
||||
expect(tableNames).toContain("mission_features");
|
||||
expect(tableNames).toContain("ai_sessions");
|
||||
expect(tableNames).toContain("messages");
|
||||
});
|
||||
|
||||
it("creates all expected indexes", () => {
|
||||
@@ -86,10 +87,13 @@ describe("Database", () => {
|
||||
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
|
||||
expect(indexNames).toContain("idxAiSessionsStatus");
|
||||
expect(indexNames).toContain("idxAiSessionsType");
|
||||
expect(indexNames).toContain("idxMessagesCreatedAt");
|
||||
expect(indexNames).toContain("idxMessagesFrom");
|
||||
expect(indexNames).toContain("idxMessagesTo");
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -112,7 +116,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -719,7 +723,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -744,11 +748,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -843,7 +847,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1053,7 +1057,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 11;
|
||||
const SCHEMA_VERSION = 12;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -451,7 +451,7 @@ export class Database {
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 12) { this.applyMigration(12, () => { ... }); }
|
||||
// if (version < 13) { this.applyMigration(13, () => { ... }); }
|
||||
|
||||
if (version < 10) {
|
||||
this.applyMigration(10, () => {
|
||||
@@ -467,6 +467,29 @@ export class Database {
|
||||
this.addColumnIfMissing("tasks", "planningModelId", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 12) {
|
||||
this.applyMigration(12, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
fromId TEXT NOT NULL,
|
||||
fromType TEXT NOT NULL,
|
||||
toId TEXT NOT NULL,
|
||||
toType TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
read INTEGER DEFAULT 0,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesTo ON messages(toId, toType, read)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesFrom ON messages(fromId, fromType)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesCreatedAt ON messages(createdAt)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { MessageStore } from "./message-store.js";
|
||||
export type { MessageStoreEvents } from "./message-store.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
|
||||
export type { Statement } from "./db.js";
|
||||
|
||||
555
packages/core/src/message-store.test.ts
Normal file
555
packages/core/src/message-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
389
packages/core/src/message-store.ts
Normal file
389
packages/core/src/message-store.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* MessageStore - Filesystem-based persistence for the messaging system.
|
||||
*
|
||||
* Messages are stored at `.fusion/messages/{messageId}.json` with their metadata.
|
||||
* An index file at `.fusion/messages/index.json` provides efficient mailbox lookups.
|
||||
*
|
||||
* File Structure:
|
||||
* - messages/{messageId}.json: Individual message data
|
||||
* - messages/index.json: Owner-to-message index for inbox/outbox queries
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
Message,
|
||||
MessageCreateInput,
|
||||
MessageFilter,
|
||||
MessageType,
|
||||
Mailbox,
|
||||
ParticipantType,
|
||||
} from "./types.js";
|
||||
|
||||
/** Events emitted by MessageStore */
|
||||
export interface MessageStoreEvents {
|
||||
/** Emitted when a new message is created and sent */
|
||||
"message:sent": (message: Message) => void;
|
||||
/** Emitted when a message is received by a participant */
|
||||
"message:received": (message: Message) => void;
|
||||
/** Emitted when a message is marked as read */
|
||||
"message:read": (message: Message) => void;
|
||||
/** Emitted when a message is deleted */
|
||||
"message:deleted": (messageId: string) => void;
|
||||
}
|
||||
|
||||
/** Options for MessageStore constructor */
|
||||
export interface MessageStoreOptions {
|
||||
/** Root directory for kb data (default: .fusion) */
|
||||
rootDir?: string;
|
||||
}
|
||||
|
||||
/** Index structure for mailbox lookups */
|
||||
interface MessageIndex {
|
||||
/** Map of "type:id" -> { inbox: [msgId, ...], outbox: [msgId, ...] } */
|
||||
byOwner: Record<string, { inbox: string[]; outbox: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageStore manages messages between agents, users, and the system.
|
||||
* Uses filesystem-based persistence following the AgentStore pattern.
|
||||
*/
|
||||
export class MessageStore extends EventEmitter {
|
||||
private rootDir: string;
|
||||
private messagesDir: string;
|
||||
private indexPath: string;
|
||||
|
||||
constructor(options: MessageStoreOptions = {}) {
|
||||
super();
|
||||
this.rootDir = options.rootDir ?? ".fusion";
|
||||
this.messagesDir = join(this.rootDir, "messages");
|
||||
this.indexPath = join(this.messagesDir, "index.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store by creating necessary directories and index file.
|
||||
* Should be called before other operations.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await mkdir(this.messagesDir, { recursive: true });
|
||||
if (!existsSync(this.indexPath)) {
|
||||
await this.writeIndex({ byOwner: {} });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and store a new message.
|
||||
* @param input - Message creation parameters
|
||||
* @returns The created message
|
||||
*/
|
||||
async sendMessage(input: MessageCreateInput): Promise<Message> {
|
||||
const now = new Date().toISOString();
|
||||
const messageId = `msg-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const fromId = input.fromId ?? "system";
|
||||
const fromType = input.fromType ?? "system";
|
||||
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
fromId,
|
||||
fromType,
|
||||
toId: input.toId,
|
||||
toType: input.toType,
|
||||
content: input.content,
|
||||
type: input.type,
|
||||
read: false,
|
||||
metadata: input.metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write message file
|
||||
await this.writeMessageFile(message);
|
||||
|
||||
// Update index
|
||||
await this.addToIndex(message);
|
||||
|
||||
this.emit("message:sent", message);
|
||||
this.emit("message:received", message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
* @param id - The message ID
|
||||
* @returns The message, or null if not found
|
||||
*/
|
||||
async getMessage(id: string): Promise<Message | null> {
|
||||
try {
|
||||
const path = join(this.messagesDir, `${id}.json`);
|
||||
const content = await readFile(path, "utf-8");
|
||||
return JSON.parse(content) as Message;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inbox messages for a participant (messages where they are the recipient).
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of messages (newest first)
|
||||
*/
|
||||
async getInbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
filter?: MessageFilter,
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const key = `${ownerType}:${ownerId}`;
|
||||
const inboxIds = index.byOwner[key]?.inbox ?? [];
|
||||
|
||||
const messages = await this.loadMessagesByIds(inboxIds);
|
||||
return this.applyFilter(messages, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbox messages for a participant (messages they sent).
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of messages (newest first)
|
||||
*/
|
||||
async getOutbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
filter?: MessageFilter,
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const key = `${ownerType}:${ownerId}`;
|
||||
const outboxIds = index.byOwner[key]?.outbox ?? [];
|
||||
|
||||
const messages = await this.loadMessagesByIds(outboxIds);
|
||||
return this.applyFilter(messages, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as read.
|
||||
* @param messageId - The message ID
|
||||
* @returns The updated message
|
||||
* @throws Error if message not found
|
||||
*/
|
||||
async markAsRead(messageId: string): Promise<Message> {
|
||||
const message = await this.getMessage(messageId);
|
||||
if (!message) {
|
||||
throw new Error(`Message ${messageId} not found`);
|
||||
}
|
||||
|
||||
if (message.read) return message;
|
||||
|
||||
const updated: Message = {
|
||||
...message,
|
||||
read: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeMessageFile(updated);
|
||||
this.emit("message:read", updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all inbox messages as read for a participant.
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @returns Number of messages marked as read
|
||||
*/
|
||||
async markAllAsRead(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
): Promise<number> {
|
||||
const inbox = await this.getInbox(ownerId, ownerType);
|
||||
const unread = inbox.filter((m) => !m.read);
|
||||
|
||||
let count = 0;
|
||||
for (const message of unread) {
|
||||
await this.markAsRead(message.id);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message by ID.
|
||||
* @param id - The message ID
|
||||
* @throws Error if message not found
|
||||
*/
|
||||
async deleteMessage(id: string): Promise<void> {
|
||||
const message = await this.getMessage(id);
|
||||
if (!message) {
|
||||
throw new Error(`Message ${id} not found`);
|
||||
}
|
||||
|
||||
// Remove message file
|
||||
const path = join(this.messagesDir, `${id}.json`);
|
||||
await unlink(path);
|
||||
|
||||
// Remove from index
|
||||
await this.removeFromIndex(message);
|
||||
|
||||
this.emit("message:deleted", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages between two participants (conversation view).
|
||||
* @param participantA - First participant
|
||||
* @param participantB - Second participant
|
||||
* @returns Array of messages (oldest first for conversation ordering)
|
||||
*/
|
||||
async getConversation(
|
||||
participantA: { id: string; type: ParticipantType },
|
||||
participantB: { id: string; type: ParticipantType },
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const keyA = `${participantA.type}:${participantA.id}`;
|
||||
const keyB = `${participantB.type}:${participantB.id}`;
|
||||
|
||||
const aInbox = index.byOwner[keyA]?.inbox ?? [];
|
||||
const aOutbox = index.byOwner[keyA]?.outbox ?? [];
|
||||
const allA = new Set([...aInbox, ...aOutbox]);
|
||||
|
||||
const bInbox = index.byOwner[keyB]?.inbox ?? [];
|
||||
const bOutbox = index.byOwner[keyB]?.outbox ?? [];
|
||||
const allB = new Set([...bInbox, ...bOutbox]);
|
||||
|
||||
// Find intersection: messages both participants have
|
||||
const conversationIds = [...allA].filter((id) => allB.has(id));
|
||||
|
||||
const messages = await this.loadMessagesByIds(conversationIds);
|
||||
// Conversation order: oldest first
|
||||
return [...messages].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mailbox summary for a participant.
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @returns Mailbox summary with unread count and last message
|
||||
*/
|
||||
async getMailbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
): Promise<Mailbox> {
|
||||
const inbox = await this.getInbox(ownerId, ownerType);
|
||||
const unreadCount = inbox.filter((m) => !m.read).length;
|
||||
const lastMessage = inbox.length > 0 ? inbox[0] : undefined;
|
||||
|
||||
return {
|
||||
ownerId,
|
||||
ownerType,
|
||||
unreadCount,
|
||||
lastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async writeMessageFile(message: Message): Promise<void> {
|
||||
const path = join(this.messagesDir, `${message.id}.json`);
|
||||
const tempPath = `${path}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(message, null, 2));
|
||||
await rename(tempPath, path);
|
||||
}
|
||||
|
||||
private async readIndex(): Promise<MessageIndex> {
|
||||
try {
|
||||
const content = await readFile(this.indexPath, "utf-8");
|
||||
return JSON.parse(content) as MessageIndex;
|
||||
} catch {
|
||||
return { byOwner: {} };
|
||||
}
|
||||
}
|
||||
|
||||
private async writeIndex(index: MessageIndex): Promise<void> {
|
||||
const tempPath = `${this.indexPath}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(index, null, 2));
|
||||
await rename(tempPath, this.indexPath);
|
||||
}
|
||||
|
||||
private async addToIndex(message: Message): Promise<void> {
|
||||
const index = await this.readIndex();
|
||||
|
||||
// Add to recipient's inbox
|
||||
const toKey = `${message.toType}:${message.toId}`;
|
||||
if (!index.byOwner[toKey]) {
|
||||
index.byOwner[toKey] = { inbox: [], outbox: [] };
|
||||
}
|
||||
index.byOwner[toKey].inbox.unshift(message.id);
|
||||
|
||||
// Add to sender's outbox
|
||||
const fromKey = `${message.fromType}:${message.fromId}`;
|
||||
if (!index.byOwner[fromKey]) {
|
||||
index.byOwner[fromKey] = { inbox: [], outbox: [] };
|
||||
}
|
||||
index.byOwner[fromKey].outbox.unshift(message.id);
|
||||
|
||||
await this.writeIndex(index);
|
||||
}
|
||||
|
||||
private async removeFromIndex(message: Message): Promise<void> {
|
||||
const index = await this.readIndex();
|
||||
|
||||
// Remove from recipient's inbox
|
||||
const toKey = `${message.toType}:${message.toId}`;
|
||||
if (index.byOwner[toKey]) {
|
||||
index.byOwner[toKey].inbox = index.byOwner[toKey].inbox.filter((id) => id !== message.id);
|
||||
index.byOwner[toKey].outbox = index.byOwner[toKey].outbox.filter((id) => id !== message.id);
|
||||
}
|
||||
|
||||
// Remove from sender's outbox
|
||||
const fromKey = `${message.fromType}:${message.fromId}`;
|
||||
if (index.byOwner[fromKey]) {
|
||||
index.byOwner[fromKey].inbox = index.byOwner[fromKey].inbox.filter((id) => id !== message.id);
|
||||
index.byOwner[fromKey].outbox = index.byOwner[fromKey].outbox.filter((id) => id !== message.id);
|
||||
}
|
||||
|
||||
await this.writeIndex(index);
|
||||
}
|
||||
|
||||
private async loadMessagesByIds(ids: string[]): Promise<Message[]> {
|
||||
const messages: Message[] = [];
|
||||
for (const id of ids) {
|
||||
const message = await this.getMessage(id);
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private applyFilter(messages: Message[], filter?: MessageFilter): Message[] {
|
||||
let result = messages;
|
||||
|
||||
if (filter?.type) {
|
||||
result = result.filter((m) => m.type === filter.type);
|
||||
}
|
||||
|
||||
if (filter?.read !== undefined) {
|
||||
result = result.filter((m) => m.read === filter.read);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const offset = filter?.offset ?? 0;
|
||||
const limit = filter?.limit ?? result.length;
|
||||
result = result.slice(offset, offset + limit);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1608,3 +1608,79 @@ export interface MigrationResult {
|
||||
/** Errors encountered during migration */
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
// ── Messaging Types ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Participant types for message routing */
|
||||
export type ParticipantType = "agent" | "user" | "system";
|
||||
|
||||
/** Message types/categories */
|
||||
export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system";
|
||||
|
||||
/** Message record stored in the system */
|
||||
export interface Message {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Sender identifier */
|
||||
fromId: string;
|
||||
/** Sender type */
|
||||
fromType: ParticipantType;
|
||||
/** Recipient identifier */
|
||||
toId: string;
|
||||
/** Recipient type */
|
||||
toType: ParticipantType;
|
||||
/** Message body */
|
||||
content: string;
|
||||
/** Message category */
|
||||
type: MessageType;
|
||||
/** Whether the recipient has read this message */
|
||||
read: boolean;
|
||||
/** Optional extra data */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new message */
|
||||
export interface MessageCreateInput {
|
||||
/** Sender identifier (auto-filled by the transport layer if omitted) */
|
||||
fromId?: string;
|
||||
/** Sender type (auto-filled by the transport layer if omitted) */
|
||||
fromType?: ParticipantType;
|
||||
/** Recipient identifier */
|
||||
toId: string;
|
||||
/** Recipient type */
|
||||
toType: ParticipantType;
|
||||
/** Message body */
|
||||
content: string;
|
||||
/** Message category */
|
||||
type: MessageType;
|
||||
/** Optional extra data */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Filter options for querying messages */
|
||||
export interface MessageFilter {
|
||||
/** Filter by message type */
|
||||
type?: MessageType;
|
||||
/** Filter by read status */
|
||||
read?: boolean;
|
||||
/** Maximum number of messages to return */
|
||||
limit?: number;
|
||||
/** Number of messages to skip (for pagination) */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Mailbox summary for a participant */
|
||||
export interface Mailbox {
|
||||
/** Owner identifier */
|
||||
ownerId: string;
|
||||
/** Owner type */
|
||||
ownerType: ParticipantType;
|
||||
/** Number of unread messages */
|
||||
unreadCount: number;
|
||||
/** Most recent message (if any) */
|
||||
lastMessage?: Message;
|
||||
}
|
||||
|
||||
@@ -529,6 +529,18 @@ When `FUSION_BADGE_PUBSUB_REDIS_URL` is not set, the dashboard uses an in-memory
|
||||
- `DELETE /api/terminal/sessions/:id` - Kill session
|
||||
- `WS /api/terminal/ws` - WebSocket connection
|
||||
|
||||
### Messaging
|
||||
- `GET /api/messages/inbox` - Fetch inbox messages (query: `limit`, `offset`, `unreadOnly`, `type`)
|
||||
- `GET /api/messages/outbox` - Fetch sent messages (query: `limit`, `offset`, `type`)
|
||||
- `GET /api/messages/unread-count` - Get unread count (for header badge)
|
||||
- `POST /api/messages` - Send a message (body: `{ toId, toType, content, type, metadata? }`)
|
||||
- `GET /api/messages/:id` - Fetch a single message
|
||||
- `POST /api/messages/:id/read` - Mark message as read
|
||||
- `POST /api/messages/read-all` - Mark all inbox as read
|
||||
- `DELETE /api/messages/:id` - Delete a message
|
||||
- `GET /api/messages/conversation/:participantType/:participantId` - Get conversation thread
|
||||
- `GET /api/agents/:id/mailbox` - View agent mailbox (admin read-only)
|
||||
|
||||
### Configuration
|
||||
- `GET /api/config` - Server configuration
|
||||
- `GET /api/settings` - Merged settings (project overrides global)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
|
||||
import type { ModelInfo, ProjectInfo } from "./api";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, fetchUnreadCount, fetchAgents } from "./api";
|
||||
import type { ModelInfo, ProjectInfo, Agent } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { ListView } from "./components/ListView";
|
||||
@@ -26,6 +26,7 @@ import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { AgentListModal } from "./components/AgentListModal";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { MailboxModal } from "./components/MailboxModal";
|
||||
import { ScriptsModal } from "./components/ScriptsModal";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
@@ -89,6 +90,9 @@ function AppInner() {
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [mailboxOpen, setMailboxOpen] = useState(false);
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
const [mailboxAgents, setMailboxAgents] = useState<Agent[]>([]);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [missionsOpen, setMissionsOpen] = useState(false);
|
||||
@@ -533,6 +537,18 @@ function AppInner() {
|
||||
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
|
||||
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
|
||||
|
||||
const handleOpenMailbox = useCallback(() => {
|
||||
setMailboxOpen(true);
|
||||
// Refresh unread count and agents when opening mailbox
|
||||
fetchUnreadCount(currentProject?.id).then((data) => {
|
||||
setMailboxUnreadCount(data.unreadCount);
|
||||
}).catch(() => {});
|
||||
fetchAgents(undefined, currentProject?.id).then((agents) => {
|
||||
setMailboxAgents(agents);
|
||||
}).catch(() => {});
|
||||
}, [currentProject?.id]);
|
||||
const handleCloseMailbox = useCallback(() => setMailboxOpen(false), []);
|
||||
|
||||
// Mission link handler from TaskCard
|
||||
const handleOpenMission = useCallback((missionId: string) => {
|
||||
setMissionTargetId(missionId);
|
||||
@@ -654,6 +670,8 @@ function AppInner() {
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
onOpenUsage={handleOpenUsage}
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenMailbox={handleOpenMailbox}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
@@ -849,6 +867,13 @@ function AppInner() {
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
<MailboxModal
|
||||
isOpen={mailboxOpen}
|
||||
onClose={handleCloseMailbox}
|
||||
projectId={currentProject?.id}
|
||||
addToast={addToast}
|
||||
agents={mailboxAgents}
|
||||
/>
|
||||
{setupWizardOpen && (
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={handleSetupComplete}
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
WorkflowStep,
|
||||
WorkflowStepInput,
|
||||
WorkflowStepResult,
|
||||
Message,
|
||||
MessageType,
|
||||
ParticipantType,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
|
||||
@@ -2914,3 +2917,129 @@ export async function fetchAiSession(id: string): Promise<AiSessionDetail | null
|
||||
export async function deleteAiSession(id: string): Promise<void> {
|
||||
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Messages API ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Response shape for GET /messages/inbox */
|
||||
export interface InboxResponse {
|
||||
messages: Message[];
|
||||
total: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/** Response shape for GET /messages/outbox */
|
||||
export interface OutboxResponse {
|
||||
messages: Message[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Response shape for GET /messages/unread-count */
|
||||
export interface UnreadCountResponse {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/** Response shape for POST /messages/read-all */
|
||||
export interface MarkAllReadResponse {
|
||||
markedAsRead: number;
|
||||
}
|
||||
|
||||
/** Response shape for GET /agents/:id/mailbox */
|
||||
export interface AgentMailboxResponse {
|
||||
ownerId: string;
|
||||
ownerType: ParticipantType;
|
||||
unreadCount: number;
|
||||
lastMessage?: Message;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/** Input for sending a message via the dashboard */
|
||||
export interface SendMessageInput {
|
||||
toId: string;
|
||||
toType: ParticipantType;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Fetch inbox messages for the current user. */
|
||||
export function fetchInbox(
|
||||
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType },
|
||||
projectId?: string,
|
||||
): Promise<InboxResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
if (options?.unreadOnly) params.set("unreadOnly", "true");
|
||||
if (options?.type) params.set("type", options.type);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<InboxResponse>(`/messages/inbox${query}`);
|
||||
}
|
||||
|
||||
/** Fetch sent messages for the current user. */
|
||||
export function fetchOutbox(
|
||||
options?: { limit?: number; offset?: number; type?: MessageType },
|
||||
projectId?: string,
|
||||
): Promise<OutboxResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
if (options?.type) params.set("type", options.type);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<OutboxResponse>(`/messages/outbox${query}`);
|
||||
}
|
||||
|
||||
/** Fetch unread message count (lightweight, for header badge). */
|
||||
export function fetchUnreadCount(projectId?: string): Promise<UnreadCountResponse> {
|
||||
return api<UnreadCountResponse>(withProjectId("/messages/unread-count", projectId));
|
||||
}
|
||||
|
||||
/** Fetch a single message by ID. */
|
||||
export function fetchMessage(id: string, projectId?: string): Promise<Message> {
|
||||
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId));
|
||||
}
|
||||
|
||||
/** Send a new message. */
|
||||
export function sendMessage(input: SendMessageInput, projectId?: string): Promise<Message> {
|
||||
return api<Message>(withProjectId("/messages", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a specific message as read. */
|
||||
export function markMessageRead(id: string, projectId?: string): Promise<Message> {
|
||||
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}/read`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark all inbox messages as read. */
|
||||
export function markAllMessagesRead(projectId?: string): Promise<MarkAllReadResponse> {
|
||||
return api<MarkAllReadResponse>(withProjectId("/messages/read-all", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a message. */
|
||||
export function deleteMessage(id: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch conversation between current user and a specific participant. */
|
||||
export function fetchConversation(
|
||||
participantId: string,
|
||||
participantType: ParticipantType,
|
||||
projectId?: string,
|
||||
): Promise<Message[]> {
|
||||
const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`;
|
||||
return api<Message[]>(withProjectId(path, projectId));
|
||||
}
|
||||
|
||||
/** Fetch an agent's mailbox (admin read-only view). */
|
||||
export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<AgentMailboxResponse> {
|
||||
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3 } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { fetchScripts } from "../api";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
@@ -30,6 +30,10 @@ export interface HeaderProps {
|
||||
activePlanningSessionCount?: number;
|
||||
onOpenUsage?: () => void;
|
||||
onOpenActivityLog?: () => void;
|
||||
/** Opens the mailbox modal */
|
||||
onOpenMailbox?: () => void;
|
||||
/** Unread message count for badge display */
|
||||
mailboxUnreadCount?: number;
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
@@ -103,6 +107,8 @@ export function Header({
|
||||
activePlanningSessionCount = 0,
|
||||
onOpenUsage,
|
||||
onOpenActivityLog,
|
||||
onOpenMailbox,
|
||||
mailboxUnreadCount = 0,
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
@@ -417,6 +423,23 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mailbox button - desktop only */}
|
||||
{!isCompact && onOpenMailbox && (
|
||||
<button
|
||||
className={`btn-icon${mailboxUnreadCount > 0 ? " btn-icon--has-indicator" : ""}`}
|
||||
onClick={onOpenMailbox}
|
||||
title={`Mailbox${mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount} unread)` : ""}`}
|
||||
data-testid="header-mailbox-btn"
|
||||
>
|
||||
<Mail size={16} />
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="btn-icon-indicator" data-testid="header-mailbox-badge">
|
||||
{mailboxUnreadCount > 9 ? "9+" : mailboxUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop actions */}
|
||||
{!isCompact && (
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
@@ -736,6 +759,18 @@ export function Header({
|
||||
<span>View Activity Log</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Mailbox - in overflow on mobile */}
|
||||
{onOpenMailbox && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenMailbox)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-mailbox-btn"
|
||||
>
|
||||
<Mail size={16} />
|
||||
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Usage - in overflow on mobile */}
|
||||
{onOpenUsage && (
|
||||
<button
|
||||
|
||||
609
packages/dashboard/app/components/MailboxModal.tsx
Normal file
609
packages/dashboard/app/components/MailboxModal.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
X,
|
||||
Mail,
|
||||
Send,
|
||||
Inbox as InboxIcon,
|
||||
Bot,
|
||||
Trash2,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
MessageSquare,
|
||||
User,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
||||
import {
|
||||
fetchInbox,
|
||||
fetchOutbox,
|
||||
fetchUnreadCount,
|
||||
fetchAgentMailbox,
|
||||
markMessageRead,
|
||||
markAllMessagesRead,
|
||||
deleteMessage,
|
||||
fetchConversation,
|
||||
type InboxResponse,
|
||||
type OutboxResponse,
|
||||
type AgentMailboxResponse,
|
||||
} from "../api";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MailboxTab = "inbox" | "outbox" | "agents";
|
||||
|
||||
interface MailboxModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
addToast?: (msg: string, type?: "success" | "error") => void;
|
||||
agents?: Agent[];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function participantLabel(id: string, type: ParticipantType): string {
|
||||
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
||||
if (type === "agent") return `Agent: ${id}`;
|
||||
return "System";
|
||||
}
|
||||
|
||||
function messageTypeLabel(type: MessageType): string {
|
||||
switch (type) {
|
||||
case "agent-to-agent": return "Agent ↔ Agent";
|
||||
case "agent-to-user": return "Agent → You";
|
||||
case "user-to-agent": return "You → Agent";
|
||||
case "system": return "System";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function MailboxModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectId,
|
||||
addToast,
|
||||
agents = [],
|
||||
}: MailboxModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
|
||||
const [inbox, setInbox] = useState<InboxResponse | null>(null);
|
||||
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────
|
||||
|
||||
const loadInbox = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchInbox({ limit: 50 }, projectId);
|
||||
setInbox(data);
|
||||
setUnreadCount(data.unreadCount);
|
||||
} catch {
|
||||
// Silently fail — empty state will show
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadOutbox = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchOutbox({ limit: 50 }, projectId);
|
||||
setOutbox(data);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadAgentMailbox = useCallback(async (agentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentMailbox(agentId, projectId);
|
||||
setAgentMailbox(data);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const refreshUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchUnreadCount(projectId);
|
||||
setUnreadCount(data.unreadCount);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Load data on tab change
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
}, [isOpen, activeTab, loadInbox, loadOutbox]);
|
||||
|
||||
// Load agent mailbox when selected
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedAgentId) return;
|
||||
loadAgentMailbox(selectedAgentId);
|
||||
}, [isOpen, selectedAgentId, loadAgentMailbox]);
|
||||
|
||||
// Refresh unread count on open
|
||||
useEffect(() => {
|
||||
if (isOpen) refreshUnreadCount();
|
||||
}, [isOpen, refreshUnreadCount]);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleOpenMessage = useCallback(async (message: Message) => {
|
||||
setSelectedMessage(message);
|
||||
// Mark as read if unread
|
||||
if (!message.read) {
|
||||
try {
|
||||
const updated = await markMessageRead(message.id, projectId);
|
||||
// Update inbox state
|
||||
setInbox((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)),
|
||||
unreadCount: Math.max(0, prev.unreadCount - 1),
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
// Load conversation thread
|
||||
try {
|
||||
const conv = await fetchConversation(message.fromId, message.fromType, projectId);
|
||||
setConversationMessages(conv);
|
||||
} catch {
|
||||
setConversationMessages([message]);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleCloseMessage = useCallback(() => {
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
const result = await markAllMessagesRead(projectId);
|
||||
setUnreadCount(0);
|
||||
setInbox((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: prev.messages.map((m) => ({ ...m, read: true })),
|
||||
unreadCount: 0,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
addToast?.(`Marked ${result.markedAsRead} messages as read`, "success");
|
||||
} catch {
|
||||
addToast?.("Failed to mark messages as read", "error");
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
|
||||
const handleDeleteMessage = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteMessage(id, projectId);
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
// Refresh current tab
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
addToast?.("Message deleted", "success");
|
||||
} catch {
|
||||
addToast?.("Failed to delete message", "error");
|
||||
}
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, addToast]);
|
||||
|
||||
const handleReply = useCallback((message: Message) => {
|
||||
setComposeRecipient({ id: message.fromId, type: message.fromType });
|
||||
setShowComposer(true);
|
||||
}, []);
|
||||
|
||||
const handleMessageSent = useCallback(() => {
|
||||
setShowComposer(false);
|
||||
setComposeRecipient(null);
|
||||
addToast?.("Message sent", "success");
|
||||
// Refresh outbox
|
||||
if (activeTab === "outbox") loadOutbox();
|
||||
}, [activeTab, loadOutbox, addToast]);
|
||||
|
||||
const handleComposeCancel = useCallback(() => {
|
||||
setShowComposer(false);
|
||||
setComposeRecipient(null);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
data-testid="mailbox-modal-overlay"
|
||||
>
|
||||
<div className="modal modal-lg mailbox-modal" data-testid="mailbox-modal">
|
||||
{/* Header */}
|
||||
<div className="modal-header mailbox-header">
|
||||
<div className="mailbox-title">
|
||||
<Mail size={18} />
|
||||
<span>Mailbox</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mailbox-header-actions">
|
||||
{activeTab === "inbox" && unreadCount > 0 && (
|
||||
<button
|
||||
className="btn-sm btn-secondary"
|
||||
onClick={handleMarkAllRead}
|
||||
title="Mark all as read"
|
||||
data-testid="mailbox-mark-all-read"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<span>Mark all read</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => {
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
data-testid="mailbox-refresh"
|
||||
>
|
||||
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
data-testid="mailbox-close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mailbox-tabs" data-testid="mailbox-tabs">
|
||||
<button
|
||||
className={`mailbox-tab ${activeTab === "inbox" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("inbox"); setSelectedMessage(null); }}
|
||||
data-testid="mailbox-tab-inbox"
|
||||
>
|
||||
<InboxIcon size={14} />
|
||||
<span>Inbox</span>
|
||||
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("outbox"); setSelectedMessage(null); }}
|
||||
data-testid="mailbox-tab-outbox"
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>Outbox</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); }}
|
||||
data-testid="mailbox-tab-agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
<span>Agents</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mailbox-content" data-testid="mailbox-content">
|
||||
{/* Message Detail View */}
|
||||
{selectedMessage && !showComposer && (
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
||||
<div className="mailbox-message-detail-header">
|
||||
<button
|
||||
className="btn-sm btn-secondary"
|
||||
onClick={handleCloseMessage}
|
||||
data-testid="mailbox-back-to-list"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="mailbox-message-detail-meta">
|
||||
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-message-detail-actions">
|
||||
{selectedMessage.fromType === "agent" && (
|
||||
<button
|
||||
className="btn-sm btn-secondary"
|
||||
onClick={() => handleReply(selectedMessage)}
|
||||
data-testid="mailbox-reply"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-sm btn-secondary"
|
||||
onClick={() => handleDeleteMessage(selectedMessage.id)}
|
||||
data-testid="mailbox-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-message-participants">
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">From:</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">To:</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{participantLabel(selectedMessage.toId, selectedMessage.toType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Conversation thread */}
|
||||
{conversationMessages.length > 1 && (
|
||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||
<div className="mailbox-conversation-label">Conversation</div>
|
||||
{conversationMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||
>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
<span>{participantLabel(msg.fromId, msg.fromType)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-conversation-msg-body">{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Full message content */}
|
||||
{(conversationMessages.length <= 1) && (
|
||||
<div className="mailbox-message-body" data-testid="mailbox-message-body">
|
||||
{selectedMessage.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Composer */}
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
recipient={composeRecipient}
|
||||
agents={agents}
|
||||
projectId={projectId}
|
||||
onSend={handleMessageSent}
|
||||
onCancel={handleComposeCancel}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab Content — message lists */}
|
||||
{!selectedMessage && !showComposer && (
|
||||
<>
|
||||
{/* Inbox Tab */}
|
||||
{activeTab === "inbox" && (
|
||||
<div className="mailbox-list" data-testid="mailbox-inbox-list">
|
||||
{isLoading && !inbox && <MailboxSkeleton />}
|
||||
{inbox && inbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-inbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No messages in your inbox</p>
|
||||
</div>
|
||||
)}
|
||||
{inbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
>
|
||||
<div className="mailbox-item-avatar">
|
||||
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{participantLabel(msg.fromId, msg.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
{!msg.read && <div className="mailbox-item-unread-dot" data-testid={`mailbox-unread-dot-${msg.id}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbox Tab */}
|
||||
{activeTab === "outbox" && (
|
||||
<div className="mailbox-list" data-testid="mailbox-outbox-list">
|
||||
{isLoading && !outbox && <MailboxSkeleton />}
|
||||
{outbox && outbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-outbox-empty">
|
||||
<Send size={32} />
|
||||
<p>No sent messages</p>
|
||||
</div>
|
||||
)}
|
||||
{outbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="mailbox-item"
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
>
|
||||
<div className="mailbox-item-avatar">
|
||||
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-to">
|
||||
To: {participantLabel(msg.toId, msg.toType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Mailboxes Tab */}
|
||||
{activeTab === "agents" && (
|
||||
<div className="mailbox-agents" data-testid="mailbox-agents">
|
||||
<div className="mailbox-agents-sidebar">
|
||||
<div className="mailbox-agents-label">Select an agent</div>
|
||||
{agents.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<Bot size={24} />
|
||||
<p>No agents found</p>
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={`mailbox-agent-btn ${selectedAgentId === agent.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
data-testid={`mailbox-agent-btn-${agent.id}`}
|
||||
>
|
||||
<Bot size={14} />
|
||||
<span>{agent.name || agent.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mailbox-agents-content">
|
||||
{!selectedAgentId && (
|
||||
<div className="mailbox-empty">
|
||||
<Bot size={32} />
|
||||
<p>Select an agent to view their mailbox</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
|
||||
{agentMailbox && agentMailbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No messages for this agent</p>
|
||||
</div>
|
||||
)}
|
||||
{agentMailbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
>
|
||||
<div className="mailbox-item-avatar">
|
||||
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{msg.fromType === "agent"
|
||||
? participantLabel(msg.toId, msg.toType)
|
||||
: participantLabel(msg.fromId, msg.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose FAB (only when viewing inbox/outbox, not in detail view or agents tab) */}
|
||||
{!selectedMessage && !showComposer && activeTab !== "agents" && (
|
||||
<button
|
||||
className="mailbox-compose-fab"
|
||||
onClick={() => setShowComposer(true)}
|
||||
title="Compose message"
|
||||
data-testid="mailbox-compose-fab"
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MailboxSkeleton() {
|
||||
return (
|
||||
<div className="mailbox-skeleton" data-testid="mailbox-skeleton">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="mailbox-skeleton-item">
|
||||
<div className="mailbox-skeleton-avatar" />
|
||||
<div className="mailbox-skeleton-content">
|
||||
<div className="mailbox-skeleton-line mailbox-skeleton-line--short" />
|
||||
<div className="mailbox-skeleton-line mailbox-skeleton-line--long" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
packages/dashboard/app/components/MessageComposer.tsx
Normal file
197
packages/dashboard/app/components/MessageComposer.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
|
||||
import type { ParticipantType, MessageType } from "@fusion/core";
|
||||
import { sendMessage } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MessageComposerProps {
|
||||
/** Pre-fill recipient (e.g. when replying) */
|
||||
recipient?: { id: string; type: ParticipantType } | null;
|
||||
/** List of agents for recipient selection */
|
||||
agents?: Agent[];
|
||||
/** Project ID for multi-project */
|
||||
projectId?: string;
|
||||
/** Called when message is successfully sent */
|
||||
onSend: () => void;
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void;
|
||||
/** Toast notification callback */
|
||||
addToast?: (msg: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
const MAX_CONTENT_LENGTH = 2000;
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageComposer({
|
||||
recipient,
|
||||
agents = [],
|
||||
projectId,
|
||||
onSend,
|
||||
onCancel,
|
||||
addToast,
|
||||
}: MessageComposerProps) {
|
||||
const [toId, setToId] = useState(recipient?.id ?? "");
|
||||
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
||||
const [content, setContent] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isValid = toId.trim() !== "" && content.trim().length > 0 && content.length <= MAX_CONTENT_LENGTH;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!isValid || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
|
||||
await sendMessage(
|
||||
{
|
||||
toId: toId.trim(),
|
||||
toType,
|
||||
content: content.trim(),
|
||||
type: messageType,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
onSend();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ?? "Failed to send message";
|
||||
setError(msg);
|
||||
addToast?.(msg, "error");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [isValid, isSending, toId, toType, content, projectId, onSend, addToast]);
|
||||
|
||||
const handleAgentSelect = useCallback((agentId: string) => {
|
||||
setToId(agentId);
|
||||
setToType("agent");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="message-composer" data-testid="message-composer">
|
||||
<div className="message-composer-header">
|
||||
<span>New Message</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onCancel}
|
||||
aria-label="Cancel"
|
||||
data-testid="message-composer-cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="message-composer-body">
|
||||
{/* Recipient selection */}
|
||||
{!recipient && (
|
||||
<div className="message-composer-field">
|
||||
<label className="message-composer-label" htmlFor="message-recipient">
|
||||
To:
|
||||
</label>
|
||||
{agents.length > 0 ? (
|
||||
<select
|
||||
id="message-recipient"
|
||||
className="message-composer-select"
|
||||
value={toId}
|
||||
onChange={(e) => handleAgentSelect(e.target.value)}
|
||||
data-testid="message-composer-recipient"
|
||||
>
|
||||
<option value="">Select agent…</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name || agent.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="message-recipient"
|
||||
className="message-composer-input"
|
||||
type="text"
|
||||
placeholder="Recipient ID"
|
||||
value={toId}
|
||||
onChange={(e) => setToId(e.target.value)}
|
||||
data-testid="message-composer-recipient"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipient display (when pre-filled from reply) */}
|
||||
{recipient && (
|
||||
<div className="message-composer-field">
|
||||
<span className="message-composer-label">To:</span>
|
||||
<span className="message-composer-recipient-fixed">
|
||||
<Bot size={14} />
|
||||
{recipient.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="message-composer-field message-composer-field--content">
|
||||
<label className="message-composer-label" htmlFor="message-content">
|
||||
Message:
|
||||
</label>
|
||||
<textarea
|
||||
id="message-content"
|
||||
className="message-composer-textarea"
|
||||
placeholder="Type your message…"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
rows={4}
|
||||
data-testid="message-composer-content"
|
||||
/>
|
||||
<div className="message-composer-charcount" data-testid="message-composer-charcount">
|
||||
<span className={content.length > MAX_CONTENT_LENGTH ? "over-limit" : ""}>
|
||||
{content.length}/{MAX_CONTENT_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="message-composer-error" data-testid="message-composer-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="message-composer-footer">
|
||||
<button
|
||||
className="btn-sm btn-secondary"
|
||||
onClick={onCancel}
|
||||
data-testid="message-composer-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-sm btn-primary"
|
||||
onClick={handleSend}
|
||||
disabled={!isValid || isSending}
|
||||
data-testid="message-composer-send"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>Sending…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={14} />
|
||||
<span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MailboxModal } from "../MailboxModal";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Agent } from "../../api";
|
||||
import type { Message } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchInbox: vi.fn(),
|
||||
fetchOutbox: vi.fn(),
|
||||
fetchUnreadCount: vi.fn(),
|
||||
fetchAgentMailbox: vi.fn(),
|
||||
markMessageRead: vi.fn(),
|
||||
markAllMessagesRead: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
fetchConversation: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
Mail: () => <span data-testid="icon-mail">Mail</span>,
|
||||
Send: () => <span data-testid="icon-send">Send</span>,
|
||||
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
|
||||
Bot: () => <span data-testid="icon-bot">Bot</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">Trash</span>,
|
||||
Check: () => <span data-testid="icon-check">Check</span>,
|
||||
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
|
||||
Loader2: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-loader" className={className}>Loader</span>
|
||||
),
|
||||
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
|
||||
MessageSquare: () => <span data-testid="icon-message">Message</span>,
|
||||
User: () => <span data-testid="icon-user">User</span>,
|
||||
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
||||
}));
|
||||
|
||||
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
||||
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
|
||||
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
|
||||
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
|
||||
const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
|
||||
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
|
||||
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Test Agent 1",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-002",
|
||||
name: "Test Agent 2",
|
||||
role: "triage",
|
||||
state: "active",
|
||||
taskId: "FN-001",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockMessage: Message = {
|
||||
id: "msg-001",
|
||||
fromId: "agent-001",
|
||||
fromType: "agent",
|
||||
toId: "dashboard",
|
||||
toType: "user",
|
||||
content: "Hello, this is a test message from the agent.",
|
||||
type: "agent-to-user",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockReadMessage: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-002",
|
||||
read: true,
|
||||
content: "This message has been read already.",
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
agents: mockAgents,
|
||||
};
|
||||
|
||||
describe("MailboxModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [mockMessage, mockReadMessage], total: 2, unreadCount: 1 });
|
||||
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
|
||||
mockDeleteMessage.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
render(<MailboxModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByTestId("mailbox-modal")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the modal when isOpen is true", () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("mailbox-modal")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the Mailbox title with unread count badge", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
expect(screen.getByText("Mailbox")).toBeDefined();
|
||||
// Wait for inbox to load which sets unreadCount
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-unread-badge")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByTestId("mailbox-unread-badge").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("renders all three tabs", () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows inbox tab as active by default", () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
const inboxTab = screen.getByTestId("mailbox-tab-inbox");
|
||||
expect(inboxTab.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads inbox on mount", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchInbox).toHaveBeenCalledWith({ limit: 50 }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows inbox messages after loading", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
|
||||
});
|
||||
// Should show both messages
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows unread dot for unread messages", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show unread dot for read messages", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId("mailbox-unread-dot-msg-002")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches to outbox tab on click", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
const outboxTab = screen.getByTestId("mailbox-tab-outbox");
|
||||
fireEvent.click(outboxTab);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchOutbox).toHaveBeenCalledWith({ limit: 50 }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state for empty outbox", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-outbox"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-outbox-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to agents tab on click", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-agents")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows agent buttons in agents tab", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-agent-btn-agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-agent-btn-agent-002")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads agent mailbox when agent is selected", async () => {
|
||||
mockFetchAgentMailbox.mockResolvedValue({
|
||||
ownerId: "agent-001",
|
||||
ownerType: "agent",
|
||||
unreadCount: 0,
|
||||
messages: [],
|
||||
});
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-agent-btn-agent-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-agent-btn-agent-001"));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentMailbox).toHaveBeenCalledWith("agent-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("opens message detail when clicking a message", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("marks message as read when opening unread message", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await waitFor(() => {
|
||||
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows back button in message detail", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to list when clicking back button", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-back-to-list"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
|
||||
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mark all read button when there are unread messages", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls markAllMessagesRead when clicking mark all read", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-mark-all-read"));
|
||||
await waitFor(() => {
|
||||
expect(mockMarkAllMessagesRead).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes message when clicking delete in detail view", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-delete")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-delete"));
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows compose FAB in inbox tab", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-compose-fab")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show compose FAB in agents tab", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mailbox-compose-fab")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading skeleton while loading", async () => {
|
||||
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-skeleton")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty inbox state when no messages", async () => {
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when clicking close button", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MailboxModal {...defaultProps} onClose={onClose} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-close")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mailbox-close"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes projectId to API calls", async () => {
|
||||
render(<MailboxModal {...defaultProps} projectId="proj-1" />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchInbox).toHaveBeenCalledWith({ limit: 50 }, "proj-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MessageComposer } from "../MessageComposer";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Agent } from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
Send: () => <span data-testid="icon-send">Send</span>,
|
||||
Loader2: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-loader" className={className}>Loader</span>
|
||||
),
|
||||
Bot: () => <span data-testid="icon-bot">Bot</span>,
|
||||
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
||||
}));
|
||||
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
onSend: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
|
||||
describe("MessageComposer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSendMessage.mockResolvedValue({
|
||||
id: "msg-new",
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Test message",
|
||||
type: "user-to-agent",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the composer with header", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
expect(screen.getByText("New Message")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows agent dropdown when agents are provided", () => {
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
const select = screen.getByTestId("message-composer-recipient");
|
||||
expect(select).toBeDefined();
|
||||
expect(select.tagName).toBe("SELECT");
|
||||
});
|
||||
|
||||
it("shows text input when no agents provided", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
const input = screen.getByTestId("message-composer-recipient");
|
||||
expect(input.tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("disables send button when content is empty", () => {
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
const sendBtn = screen.getByTestId("message-composer-send");
|
||||
expect(sendBtn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables send button when recipient and content are filled", () => {
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
// Select agent
|
||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||
target: { value: "agent-001" },
|
||||
});
|
||||
// Type content
|
||||
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||
target: { value: "Hello agent!" },
|
||||
});
|
||||
const sendBtn = screen.getByTestId("message-composer-send");
|
||||
expect(sendBtn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows character count", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
expect(screen.getByTestId("message-composer-charcount")).toBeDefined();
|
||||
expect(screen.getByTestId("message-composer-charcount").textContent).toContain("0/2000");
|
||||
});
|
||||
|
||||
it("updates character count when typing", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
const textarea = screen.getByTestId("message-composer-content");
|
||||
fireEvent.change(textarea, { target: { value: "Hello" } });
|
||||
expect(screen.getByTestId("message-composer-charcount").textContent).toContain("5/2000");
|
||||
});
|
||||
|
||||
it("calls onSend when message is sent successfully", async () => {
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||
target: { value: "agent-001" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||
target: { value: "Hello agent!" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Hello agent!",
|
||||
type: "user-to-agent",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(defaultProps.onSend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows error when send fails", async () => {
|
||||
mockSendMessage.mockRejectedValue(new Error("Network error"));
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||
target: { value: "agent-001" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||
target: { value: "Hello agent!" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("message-composer-error")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByTestId("message-composer-error").textContent).toContain("Network error");
|
||||
});
|
||||
|
||||
it("calls onCancel when clicking cancel button", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("message-composer-cancel"));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCancel when clicking cancel footer button", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("message-composer-cancel-btn"));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("pre-fills recipient when provided", () => {
|
||||
render(
|
||||
<MessageComposer
|
||||
{...defaultProps}
|
||||
recipient={{ id: "agent-001", type: "agent" }}
|
||||
/>,
|
||||
);
|
||||
// When recipient is pre-filled, it shows a fixed label instead of dropdown
|
||||
expect(screen.getByText("agent-001")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows loading state while sending", async () => {
|
||||
mockSendMessage.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||
target: { value: "agent-001" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||
target: { value: "Hello agent!" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("icon-loader")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId to sendMessage", async () => {
|
||||
render(<MessageComposer {...defaultProps} agents={mockAgents} projectId="proj-1" />);
|
||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||
target: { value: "agent-001" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||
target: { value: "Hello!" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"proj-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21138,3 +21138,549 @@ html .column.drag-over * {
|
||||
.text-secondary {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Mailbox Modal ───────────────────────────────────────────────────── */
|
||||
|
||||
.mailbox-modal {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.mailbox-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mailbox-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mailbox-unread-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-error, #ef4444);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mailbox-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.mailbox-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mailbox-tab.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-error, #ef4444);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-content {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 140px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mailbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mailbox-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mailbox-empty p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mailbox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.mailbox-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mailbox-item.unread {
|
||||
background: var(--bg-active);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mailbox-item-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mailbox-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mailbox-item-from,
|
||||
.mailbox-item-to {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mailbox-item-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-item-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mailbox-item-unread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Message detail view */
|
||||
.mailbox-message-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mailbox-message-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mailbox-message-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mailbox-message-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-message-type {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mailbox-message-time {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mailbox-message-participants {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mailbox-participant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mailbox-participant-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mailbox-participant-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mailbox-message-body {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Conversation thread */
|
||||
.mailbox-conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-conversation-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-conversation-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.mailbox-conversation-msg.current {
|
||||
border-left-color: var(--primary);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.mailbox-conversation-msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mailbox-conversation-msg-body {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Agents tab */
|
||||
.mailbox-agents {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.mailbox-agents-sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-right: 1px solid var(--border);
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.mailbox-agents-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-agent-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.mailbox-agent-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mailbox-agent-btn.active {
|
||||
background: var(--bg-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-agents-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Compose FAB */
|
||||
.mailbox-compose-fab {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: background-color 0.15s, transform 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mailbox-compose-fab:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.mailbox-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-line {
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-line--short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.mailbox-skeleton-line--long {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* ── Message Composer ──────────────────────────────────────────────── */
|
||||
|
||||
.message-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-composer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.message-composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-composer-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-composer-field--content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-composer-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 60px;
|
||||
padding-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-composer-select,
|
||||
.message-composer-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.message-composer-recipient-fixed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-composer-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-composer-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.message-composer-charcount {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-composer-charcount .over-limit {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.message-composer-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-error, rgba(239, 68, 68, 0.1));
|
||||
border-radius: 6px;
|
||||
color: var(--color-error, #ef4444);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.message-composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import multer from "multer";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolve, sep } from "node:path";
|
||||
import { resolve, sep, join } from "node:path";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodeChildProcess from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData } from "@fusion/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -8356,6 +8356,234 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Messaging Routes ──────────────────────────────────────────────────
|
||||
|
||||
/** Cache of MessageStore instances keyed by rootDir */
|
||||
const messageStoreCache = new Map<string, MessageStore>();
|
||||
|
||||
async function getMessageStore(req: Request): Promise<MessageStore> {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
let msgStore = messageStoreCache.get(rootDir);
|
||||
if (!msgStore) {
|
||||
msgStore = new MessageStore({ rootDir: join(rootDir, ".fusion") });
|
||||
await msgStore.init();
|
||||
messageStoreCache.set(rootDir, msgStore);
|
||||
}
|
||||
return msgStore;
|
||||
}
|
||||
|
||||
const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"];
|
||||
const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"];
|
||||
const DASHBOARD_USER_ID = "dashboard";
|
||||
|
||||
/**
|
||||
* GET /api/messages/inbox
|
||||
* Fetch inbox messages for the dashboard user.
|
||||
* Query params: limit, offset, unreadOnly, type
|
||||
*/
|
||||
router.get("/messages/inbox", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const filter = {
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
read: req.query.unreadOnly === "true" ? false : undefined,
|
||||
type: req.query.type as MessageType | undefined,
|
||||
};
|
||||
const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter);
|
||||
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
|
||||
res.json({ messages, total: messages.length, unreadCount: mailbox.unreadCount });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/messages/outbox
|
||||
* Fetch sent messages for the dashboard user.
|
||||
* Query params: limit, offset, type
|
||||
*/
|
||||
router.get("/messages/outbox", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const filter = {
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
type: req.query.type as MessageType | undefined,
|
||||
};
|
||||
const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter);
|
||||
res.json({ messages, total: messages.length });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/messages/unread-count
|
||||
* Get unread message count (lightweight for header badge).
|
||||
*/
|
||||
router.get("/messages/unread-count", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
|
||||
res.json({ unreadCount: mailbox.unreadCount });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/messages/read-all
|
||||
* Mark all inbox messages as read.
|
||||
* IMPORTANT: Must be registered before /messages/:id to avoid path conflicts.
|
||||
*/
|
||||
router.post("/messages/read-all", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const count = await msgStore.markAllAsRead(DASHBOARD_USER_ID, "user");
|
||||
res.json({ markedAsRead: count });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/messages
|
||||
* Send a new message.
|
||||
* Body: { toId, toType, content, type, metadata? }
|
||||
*/
|
||||
router.post("/messages", async (req, res) => {
|
||||
try {
|
||||
const { toId, toType, content, type, metadata } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!toId || typeof toId !== "string") {
|
||||
res.status(400).json({ error: "toId is required" });
|
||||
return;
|
||||
}
|
||||
if (!toType || !VALID_PARTICIPANT_TYPES.includes(toType)) {
|
||||
res.status(400).json({ error: `toType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
if (!content || typeof content !== "string" || content.length === 0 || content.length > 2000) {
|
||||
res.status(400).json({ error: "content is required and must be 1-2000 characters" });
|
||||
return;
|
||||
}
|
||||
if (!type || !VALID_MESSAGE_TYPES.includes(type)) {
|
||||
res.status(400).json({ error: `type must be one of: ${VALID_MESSAGE_TYPES.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const msgStore = await getMessageStore(req);
|
||||
const message = await msgStore.sendMessage({
|
||||
fromId: DASHBOARD_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
toType,
|
||||
content,
|
||||
type,
|
||||
metadata,
|
||||
});
|
||||
res.status(201).json(message);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/messages/conversation/:participantType/:participantId
|
||||
* Get conversation between dashboard user and a specific participant.
|
||||
*/
|
||||
router.get("/messages/conversation/:participantType/:participantId", async (req, res) => {
|
||||
try {
|
||||
const { participantType, participantId } = req.params;
|
||||
if (!VALID_PARTICIPANT_TYPES.includes(participantType as ParticipantType)) {
|
||||
res.status(400).json({ error: `participantType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const msgStore = await getMessageStore(req);
|
||||
const messages = await msgStore.getConversation(
|
||||
{ id: DASHBOARD_USER_ID, type: "user" },
|
||||
{ id: participantId, type: participantType as ParticipantType },
|
||||
);
|
||||
res.json(messages);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/messages/:id
|
||||
* Fetch a single message.
|
||||
*/
|
||||
router.get("/messages/:id", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const message = await msgStore.getMessage(req.params.id);
|
||||
if (!message) {
|
||||
res.status(404).json({ error: "Message not found" });
|
||||
return;
|
||||
}
|
||||
res.json(message);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/messages/:id/read
|
||||
* Mark a specific message as read.
|
||||
*/
|
||||
router.post("/messages/:id/read", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const message = await msgStore.markAsRead(req.params.id);
|
||||
res.json(message);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/messages/:id
|
||||
* Delete a message.
|
||||
*/
|
||||
router.delete("/messages/:id", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
await msgStore.deleteMessage(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/mailbox
|
||||
* View an agent's mailbox (admin read-only access).
|
||||
*/
|
||||
router.get("/agents/:id/mailbox", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const agentId = req.params.id;
|
||||
const mailbox = await msgStore.getMailbox(agentId, "agent");
|
||||
const inbox = await msgStore.getInbox(agentId, "agent");
|
||||
res.json({ ...mailbox, messages: inbox });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user