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

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

View File

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