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

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

View File

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

View File

@@ -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;

View File

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

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

View File

@@ -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();

View File

@@ -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)`);
});
}
}
/**

View File

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

View File

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

View 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;
}
}

View File

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

View File

@@ -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)

View File

@@ -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}

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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