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:
@@ -70,6 +70,7 @@ describe("Database", () => {
|
||||
expect(tableNames).toContain("slices");
|
||||
expect(tableNames).toContain("mission_features");
|
||||
expect(tableNames).toContain("ai_sessions");
|
||||
expect(tableNames).toContain("messages");
|
||||
});
|
||||
|
||||
it("creates all expected indexes", () => {
|
||||
@@ -86,10 +87,13 @@ describe("Database", () => {
|
||||
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
|
||||
expect(indexNames).toContain("idxAiSessionsStatus");
|
||||
expect(indexNames).toContain("idxAiSessionsType");
|
||||
expect(indexNames).toContain("idxMessagesCreatedAt");
|
||||
expect(indexNames).toContain("idxMessagesFrom");
|
||||
expect(indexNames).toContain("idxMessagesTo");
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -112,7 +116,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -719,7 +723,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -744,11 +748,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -843,7 +847,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1053,7 +1057,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getSchemaVersion()).toBe(12);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 11;
|
||||
const SCHEMA_VERSION = 12;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -451,7 +451,7 @@ export class Database {
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 12) { this.applyMigration(12, () => { ... }); }
|
||||
// if (version < 13) { this.applyMigration(13, () => { ... }); }
|
||||
|
||||
if (version < 10) {
|
||||
this.applyMigration(10, () => {
|
||||
@@ -467,6 +467,29 @@ export class Database {
|
||||
this.addColumnIfMissing("tasks", "planningModelId", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 12) {
|
||||
this.applyMigration(12, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
fromId TEXT NOT NULL,
|
||||
fromType TEXT NOT NULL,
|
||||
toId TEXT NOT NULL,
|
||||
toType TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
read INTEGER DEFAULT 0,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesTo ON messages(toId, toType, read)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesFrom ON messages(fromId, fromType)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMessagesCreatedAt ON messages(createdAt)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { MessageStore } from "./message-store.js";
|
||||
export type { MessageStoreEvents } from "./message-store.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
|
||||
export type { Statement } from "./db.js";
|
||||
|
||||
555
packages/core/src/message-store.test.ts
Normal file
555
packages/core/src/message-store.test.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { MessageStore } from "./message-store.js";
|
||||
import type { Message, Mailbox } from "./types.js";
|
||||
|
||||
describe("MessageStore", () => {
|
||||
let store: MessageStore;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-msg-test-"));
|
||||
store = new MessageStore({ rootDir: tempDir });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("init()", () => {
|
||||
it("creates messages directory and index file", async () => {
|
||||
const { existsSync } = await import("node:fs");
|
||||
expect(existsSync(join(tempDir, "messages"))).toBe(true);
|
||||
expect(existsSync(join(tempDir, "messages", "index.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent — calling init twice does not throw", async () => {
|
||||
await store.init();
|
||||
await store.init();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage() and getMessage()", () => {
|
||||
it("creates and retrieves a message", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Hello agent!",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
expect(message.id).toBeTruthy();
|
||||
expect(message.id).toMatch(/^msg-/);
|
||||
expect(message.fromId).toBe("user-1");
|
||||
expect(message.fromType).toBe("user");
|
||||
expect(message.toId).toBe("agent-1");
|
||||
expect(message.toType).toBe("agent");
|
||||
expect(message.content).toBe("Hello agent!");
|
||||
expect(message.type).toBe("user-to-agent");
|
||||
expect(message.read).toBe(false);
|
||||
expect(message.createdAt).toBeTruthy();
|
||||
expect(message.updatedAt).toBeTruthy();
|
||||
|
||||
const retrieved = await store.getMessage(message.id);
|
||||
expect(retrieved).toEqual(message);
|
||||
});
|
||||
|
||||
it("auto-fills sender as system when not provided", async () => {
|
||||
const message = await store.sendMessage({
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "System notification",
|
||||
type: "system",
|
||||
});
|
||||
|
||||
expect(message.fromId).toBe("system");
|
||||
expect(message.fromType).toBe("system");
|
||||
});
|
||||
|
||||
it("stores metadata when provided", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Task completed",
|
||||
type: "agent-to-user",
|
||||
metadata: { taskId: "FN-001", priority: "high" },
|
||||
});
|
||||
|
||||
expect(message.metadata).toEqual({ taskId: "FN-001", priority: "high" });
|
||||
});
|
||||
|
||||
it("returns null for non-existent message", async () => {
|
||||
const result = await store.getMessage("msg-nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInbox()", () => {
|
||||
it("returns inbox messages for a participant", async () => {
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Message 1",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Message 2",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
const inbox = await store.getInbox("user-1", "user");
|
||||
expect(inbox).toHaveLength(2);
|
||||
// Newest first
|
||||
expect(inbox[0].content).toBe("Message 2");
|
||||
expect(inbox[1].content).toBe("Message 1");
|
||||
});
|
||||
|
||||
it("returns empty array for participant with no messages", async () => {
|
||||
const inbox = await store.getInbox("user-99", "user");
|
||||
expect(inbox).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters by read status", async () => {
|
||||
const msg1 = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Unread",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
const msg2 = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Will be read",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.markAsRead(msg2.id);
|
||||
|
||||
const unreadOnly = await store.getInbox("user-1", "user", { read: false });
|
||||
expect(unreadOnly).toHaveLength(1);
|
||||
expect(unreadOnly[0].id).toBe(msg1.id);
|
||||
|
||||
const readOnly = await store.getInbox("user-1", "user", { read: true });
|
||||
expect(readOnly).toHaveLength(1);
|
||||
expect(readOnly[0].id).toBe(msg2.id);
|
||||
});
|
||||
|
||||
it("applies pagination (limit/offset)", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: `Message ${i}`,
|
||||
type: "agent-to-user",
|
||||
});
|
||||
}
|
||||
|
||||
const page1 = await store.getInbox("user-1", "user", { limit: 2, offset: 0 });
|
||||
expect(page1).toHaveLength(2);
|
||||
|
||||
const page2 = await store.getInbox("user-1", "user", { limit: 2, offset: 2 });
|
||||
expect(page2).toHaveLength(2);
|
||||
|
||||
// No overlap
|
||||
expect(page1[0].id).not.toBe(page2[0].id);
|
||||
});
|
||||
|
||||
it("filters by message type", async () => {
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Agent message",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "system",
|
||||
fromType: "system",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "System message",
|
||||
type: "system",
|
||||
});
|
||||
|
||||
const agentOnly = await store.getInbox("user-1", "user", { type: "agent-to-user" });
|
||||
expect(agentOnly).toHaveLength(1);
|
||||
expect(agentOnly[0].type).toBe("agent-to-user");
|
||||
|
||||
const systemOnly = await store.getInbox("user-1", "user", { type: "system" });
|
||||
expect(systemOnly).toHaveLength(1);
|
||||
expect(systemOnly[0].type).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOutbox()", () => {
|
||||
it("returns sent messages for a participant", async () => {
|
||||
await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Outgoing 1",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-2",
|
||||
toType: "agent",
|
||||
content: "Outgoing 2",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
const outbox = await store.getOutbox("user-1", "user");
|
||||
expect(outbox).toHaveLength(2);
|
||||
expect(outbox[0].content).toBe("Outgoing 2");
|
||||
expect(outbox[1].content).toBe("Outgoing 1");
|
||||
});
|
||||
|
||||
it("returns empty array when no messages sent", async () => {
|
||||
const outbox = await store.getOutbox("user-99", "user");
|
||||
expect(outbox).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead()", () => {
|
||||
it("marks a message as read", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Read me",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
expect(message.read).toBe(false);
|
||||
|
||||
const updated = await store.markAsRead(message.id);
|
||||
expect(updated.read).toBe(true);
|
||||
|
||||
const retrieved = await store.getMessage(message.id);
|
||||
expect(retrieved!.read).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent for already-read messages", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Already read",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.markAsRead(message.id);
|
||||
const updated = await store.markAsRead(message.id);
|
||||
expect(updated.read).toBe(true);
|
||||
});
|
||||
|
||||
it("throws for non-existent message", async () => {
|
||||
await expect(store.markAsRead("msg-nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAllAsRead()", () => {
|
||||
it("marks all unread messages as read", async () => {
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Msg 1",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Msg 2",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
const count = await store.markAllAsRead("user-1", "user");
|
||||
expect(count).toBe(2);
|
||||
|
||||
const inbox = await store.getInbox("user-1", "user");
|
||||
expect(inbox.every((m) => m.read)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 0 when no unread messages", async () => {
|
||||
const count = await store.markAllAsRead("user-99", "user");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMessage()", () => {
|
||||
it("deletes a message", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Delete me",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
await store.deleteMessage(message.id);
|
||||
|
||||
const retrieved = await store.getMessage(message.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("removes message from inbox index", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Delete me",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.deleteMessage(message.id);
|
||||
|
||||
const inbox = await store.getInbox("user-1", "user");
|
||||
expect(inbox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes message from outbox index", async () => {
|
||||
const message = await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Delete me",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
await store.deleteMessage(message.id);
|
||||
|
||||
const outbox = await store.getOutbox("user-1", "user");
|
||||
expect(outbox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws for non-existent message", async () => {
|
||||
await expect(store.deleteMessage("msg-nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConversation()", () => {
|
||||
it("returns all messages between two participants", async () => {
|
||||
// user-1 sends to agent-1
|
||||
await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Hello",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
// agent-1 replies to user-1
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Hi there",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
// Unrelated message
|
||||
await store.sendMessage({
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Unrelated",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
const conversation = await store.getConversation(
|
||||
{ id: "user-1", type: "user" },
|
||||
{ id: "agent-1", type: "agent" },
|
||||
);
|
||||
|
||||
expect(conversation).toHaveLength(2);
|
||||
// Oldest first
|
||||
expect(conversation[0].content).toBe("Hello");
|
||||
expect(conversation[1].content).toBe("Hi there");
|
||||
});
|
||||
|
||||
it("returns empty array when no conversation exists", async () => {
|
||||
const conversation = await store.getConversation(
|
||||
{ id: "user-1", type: "user" },
|
||||
{ id: "agent-99", type: "agent" },
|
||||
);
|
||||
expect(conversation).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMailbox()", () => {
|
||||
it("returns mailbox summary with unread count", async () => {
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Unread 1",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Unread 2",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
const mailbox = await store.getMailbox("user-1", "user");
|
||||
|
||||
expect(mailbox.ownerId).toBe("user-1");
|
||||
expect(mailbox.ownerType).toBe("user");
|
||||
expect(mailbox.unreadCount).toBe(2);
|
||||
expect(mailbox.lastMessage).toBeTruthy();
|
||||
expect(mailbox.lastMessage!.content).toBe("Unread 2");
|
||||
});
|
||||
|
||||
it("returns 0 unread when no messages", async () => {
|
||||
const mailbox = await store.getMailbox("user-99", "user");
|
||||
expect(mailbox.unreadCount).toBe(0);
|
||||
expect(mailbox.lastMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts only unread messages", async () => {
|
||||
const msg1 = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Will be read",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Stays unread",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.markAsRead(msg1.id);
|
||||
|
||||
const mailbox = await store.getMailbox("user-1", "user");
|
||||
expect(mailbox.unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("events", () => {
|
||||
it("emits message:sent event on send", async () => {
|
||||
const events: Message[] = [];
|
||||
store.on("message:sent", (msg) => events.push(msg));
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Hello",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].content).toBe("Hello");
|
||||
});
|
||||
|
||||
it("emits message:received event on send", async () => {
|
||||
const events: Message[] = [];
|
||||
store.on("message:received", (msg) => events.push(msg));
|
||||
|
||||
await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Hello",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits message:read event on mark as read", async () => {
|
||||
const events: Message[] = [];
|
||||
store.on("message:read", (msg) => events.push(msg));
|
||||
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user-1",
|
||||
toType: "user",
|
||||
content: "Read me",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
|
||||
await store.markAsRead(message.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].read).toBe(true);
|
||||
});
|
||||
|
||||
it("emits message:deleted event on delete", async () => {
|
||||
const events: string[] = [];
|
||||
store.on("message:deleted", (id) => events.push(id));
|
||||
|
||||
const message = await store.sendMessage({
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
content: "Delete me",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
await store.deleteMessage(message.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBe(message.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
389
packages/core/src/message-store.ts
Normal file
389
packages/core/src/message-store.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* MessageStore - Filesystem-based persistence for the messaging system.
|
||||
*
|
||||
* Messages are stored at `.fusion/messages/{messageId}.json` with their metadata.
|
||||
* An index file at `.fusion/messages/index.json` provides efficient mailbox lookups.
|
||||
*
|
||||
* File Structure:
|
||||
* - messages/{messageId}.json: Individual message data
|
||||
* - messages/index.json: Owner-to-message index for inbox/outbox queries
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
Message,
|
||||
MessageCreateInput,
|
||||
MessageFilter,
|
||||
MessageType,
|
||||
Mailbox,
|
||||
ParticipantType,
|
||||
} from "./types.js";
|
||||
|
||||
/** Events emitted by MessageStore */
|
||||
export interface MessageStoreEvents {
|
||||
/** Emitted when a new message is created and sent */
|
||||
"message:sent": (message: Message) => void;
|
||||
/** Emitted when a message is received by a participant */
|
||||
"message:received": (message: Message) => void;
|
||||
/** Emitted when a message is marked as read */
|
||||
"message:read": (message: Message) => void;
|
||||
/** Emitted when a message is deleted */
|
||||
"message:deleted": (messageId: string) => void;
|
||||
}
|
||||
|
||||
/** Options for MessageStore constructor */
|
||||
export interface MessageStoreOptions {
|
||||
/** Root directory for kb data (default: .fusion) */
|
||||
rootDir?: string;
|
||||
}
|
||||
|
||||
/** Index structure for mailbox lookups */
|
||||
interface MessageIndex {
|
||||
/** Map of "type:id" -> { inbox: [msgId, ...], outbox: [msgId, ...] } */
|
||||
byOwner: Record<string, { inbox: string[]; outbox: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageStore manages messages between agents, users, and the system.
|
||||
* Uses filesystem-based persistence following the AgentStore pattern.
|
||||
*/
|
||||
export class MessageStore extends EventEmitter {
|
||||
private rootDir: string;
|
||||
private messagesDir: string;
|
||||
private indexPath: string;
|
||||
|
||||
constructor(options: MessageStoreOptions = {}) {
|
||||
super();
|
||||
this.rootDir = options.rootDir ?? ".fusion";
|
||||
this.messagesDir = join(this.rootDir, "messages");
|
||||
this.indexPath = join(this.messagesDir, "index.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store by creating necessary directories and index file.
|
||||
* Should be called before other operations.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await mkdir(this.messagesDir, { recursive: true });
|
||||
if (!existsSync(this.indexPath)) {
|
||||
await this.writeIndex({ byOwner: {} });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and store a new message.
|
||||
* @param input - Message creation parameters
|
||||
* @returns The created message
|
||||
*/
|
||||
async sendMessage(input: MessageCreateInput): Promise<Message> {
|
||||
const now = new Date().toISOString();
|
||||
const messageId = `msg-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const fromId = input.fromId ?? "system";
|
||||
const fromType = input.fromType ?? "system";
|
||||
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
fromId,
|
||||
fromType,
|
||||
toId: input.toId,
|
||||
toType: input.toType,
|
||||
content: input.content,
|
||||
type: input.type,
|
||||
read: false,
|
||||
metadata: input.metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write message file
|
||||
await this.writeMessageFile(message);
|
||||
|
||||
// Update index
|
||||
await this.addToIndex(message);
|
||||
|
||||
this.emit("message:sent", message);
|
||||
this.emit("message:received", message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
* @param id - The message ID
|
||||
* @returns The message, or null if not found
|
||||
*/
|
||||
async getMessage(id: string): Promise<Message | null> {
|
||||
try {
|
||||
const path = join(this.messagesDir, `${id}.json`);
|
||||
const content = await readFile(path, "utf-8");
|
||||
return JSON.parse(content) as Message;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inbox messages for a participant (messages where they are the recipient).
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of messages (newest first)
|
||||
*/
|
||||
async getInbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
filter?: MessageFilter,
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const key = `${ownerType}:${ownerId}`;
|
||||
const inboxIds = index.byOwner[key]?.inbox ?? [];
|
||||
|
||||
const messages = await this.loadMessagesByIds(inboxIds);
|
||||
return this.applyFilter(messages, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbox messages for a participant (messages they sent).
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of messages (newest first)
|
||||
*/
|
||||
async getOutbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
filter?: MessageFilter,
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const key = `${ownerType}:${ownerId}`;
|
||||
const outboxIds = index.byOwner[key]?.outbox ?? [];
|
||||
|
||||
const messages = await this.loadMessagesByIds(outboxIds);
|
||||
return this.applyFilter(messages, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as read.
|
||||
* @param messageId - The message ID
|
||||
* @returns The updated message
|
||||
* @throws Error if message not found
|
||||
*/
|
||||
async markAsRead(messageId: string): Promise<Message> {
|
||||
const message = await this.getMessage(messageId);
|
||||
if (!message) {
|
||||
throw new Error(`Message ${messageId} not found`);
|
||||
}
|
||||
|
||||
if (message.read) return message;
|
||||
|
||||
const updated: Message = {
|
||||
...message,
|
||||
read: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.writeMessageFile(updated);
|
||||
this.emit("message:read", updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all inbox messages as read for a participant.
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @returns Number of messages marked as read
|
||||
*/
|
||||
async markAllAsRead(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
): Promise<number> {
|
||||
const inbox = await this.getInbox(ownerId, ownerType);
|
||||
const unread = inbox.filter((m) => !m.read);
|
||||
|
||||
let count = 0;
|
||||
for (const message of unread) {
|
||||
await this.markAsRead(message.id);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message by ID.
|
||||
* @param id - The message ID
|
||||
* @throws Error if message not found
|
||||
*/
|
||||
async deleteMessage(id: string): Promise<void> {
|
||||
const message = await this.getMessage(id);
|
||||
if (!message) {
|
||||
throw new Error(`Message ${id} not found`);
|
||||
}
|
||||
|
||||
// Remove message file
|
||||
const path = join(this.messagesDir, `${id}.json`);
|
||||
await unlink(path);
|
||||
|
||||
// Remove from index
|
||||
await this.removeFromIndex(message);
|
||||
|
||||
this.emit("message:deleted", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages between two participants (conversation view).
|
||||
* @param participantA - First participant
|
||||
* @param participantB - Second participant
|
||||
* @returns Array of messages (oldest first for conversation ordering)
|
||||
*/
|
||||
async getConversation(
|
||||
participantA: { id: string; type: ParticipantType },
|
||||
participantB: { id: string; type: ParticipantType },
|
||||
): Promise<Message[]> {
|
||||
const index = await this.readIndex();
|
||||
const keyA = `${participantA.type}:${participantA.id}`;
|
||||
const keyB = `${participantB.type}:${participantB.id}`;
|
||||
|
||||
const aInbox = index.byOwner[keyA]?.inbox ?? [];
|
||||
const aOutbox = index.byOwner[keyA]?.outbox ?? [];
|
||||
const allA = new Set([...aInbox, ...aOutbox]);
|
||||
|
||||
const bInbox = index.byOwner[keyB]?.inbox ?? [];
|
||||
const bOutbox = index.byOwner[keyB]?.outbox ?? [];
|
||||
const allB = new Set([...bInbox, ...bOutbox]);
|
||||
|
||||
// Find intersection: messages both participants have
|
||||
const conversationIds = [...allA].filter((id) => allB.has(id));
|
||||
|
||||
const messages = await this.loadMessagesByIds(conversationIds);
|
||||
// Conversation order: oldest first
|
||||
return [...messages].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mailbox summary for a participant.
|
||||
* @param ownerId - The participant ID
|
||||
* @param ownerType - The participant type
|
||||
* @returns Mailbox summary with unread count and last message
|
||||
*/
|
||||
async getMailbox(
|
||||
ownerId: string,
|
||||
ownerType: ParticipantType,
|
||||
): Promise<Mailbox> {
|
||||
const inbox = await this.getInbox(ownerId, ownerType);
|
||||
const unreadCount = inbox.filter((m) => !m.read).length;
|
||||
const lastMessage = inbox.length > 0 ? inbox[0] : undefined;
|
||||
|
||||
return {
|
||||
ownerId,
|
||||
ownerType,
|
||||
unreadCount,
|
||||
lastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async writeMessageFile(message: Message): Promise<void> {
|
||||
const path = join(this.messagesDir, `${message.id}.json`);
|
||||
const tempPath = `${path}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(message, null, 2));
|
||||
await rename(tempPath, path);
|
||||
}
|
||||
|
||||
private async readIndex(): Promise<MessageIndex> {
|
||||
try {
|
||||
const content = await readFile(this.indexPath, "utf-8");
|
||||
return JSON.parse(content) as MessageIndex;
|
||||
} catch {
|
||||
return { byOwner: {} };
|
||||
}
|
||||
}
|
||||
|
||||
private async writeIndex(index: MessageIndex): Promise<void> {
|
||||
const tempPath = `${this.indexPath}.tmp.${Date.now()}`;
|
||||
await writeFile(tempPath, JSON.stringify(index, null, 2));
|
||||
await rename(tempPath, this.indexPath);
|
||||
}
|
||||
|
||||
private async addToIndex(message: Message): Promise<void> {
|
||||
const index = await this.readIndex();
|
||||
|
||||
// Add to recipient's inbox
|
||||
const toKey = `${message.toType}:${message.toId}`;
|
||||
if (!index.byOwner[toKey]) {
|
||||
index.byOwner[toKey] = { inbox: [], outbox: [] };
|
||||
}
|
||||
index.byOwner[toKey].inbox.unshift(message.id);
|
||||
|
||||
// Add to sender's outbox
|
||||
const fromKey = `${message.fromType}:${message.fromId}`;
|
||||
if (!index.byOwner[fromKey]) {
|
||||
index.byOwner[fromKey] = { inbox: [], outbox: [] };
|
||||
}
|
||||
index.byOwner[fromKey].outbox.unshift(message.id);
|
||||
|
||||
await this.writeIndex(index);
|
||||
}
|
||||
|
||||
private async removeFromIndex(message: Message): Promise<void> {
|
||||
const index = await this.readIndex();
|
||||
|
||||
// Remove from recipient's inbox
|
||||
const toKey = `${message.toType}:${message.toId}`;
|
||||
if (index.byOwner[toKey]) {
|
||||
index.byOwner[toKey].inbox = index.byOwner[toKey].inbox.filter((id) => id !== message.id);
|
||||
index.byOwner[toKey].outbox = index.byOwner[toKey].outbox.filter((id) => id !== message.id);
|
||||
}
|
||||
|
||||
// Remove from sender's outbox
|
||||
const fromKey = `${message.fromType}:${message.fromId}`;
|
||||
if (index.byOwner[fromKey]) {
|
||||
index.byOwner[fromKey].inbox = index.byOwner[fromKey].inbox.filter((id) => id !== message.id);
|
||||
index.byOwner[fromKey].outbox = index.byOwner[fromKey].outbox.filter((id) => id !== message.id);
|
||||
}
|
||||
|
||||
await this.writeIndex(index);
|
||||
}
|
||||
|
||||
private async loadMessagesByIds(ids: string[]): Promise<Message[]> {
|
||||
const messages: Message[] = [];
|
||||
for (const id of ids) {
|
||||
const message = await this.getMessage(id);
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private applyFilter(messages: Message[], filter?: MessageFilter): Message[] {
|
||||
let result = messages;
|
||||
|
||||
if (filter?.type) {
|
||||
result = result.filter((m) => m.type === filter.type);
|
||||
}
|
||||
|
||||
if (filter?.read !== undefined) {
|
||||
result = result.filter((m) => m.read === filter.read);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const offset = filter?.offset ?? 0;
|
||||
const limit = filter?.limit ?? result.length;
|
||||
result = result.slice(offset, offset + limit);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1608,3 +1608,79 @@ export interface MigrationResult {
|
||||
/** Errors encountered during migration */
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
// ── Messaging Types ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Participant types for message routing */
|
||||
export type ParticipantType = "agent" | "user" | "system";
|
||||
|
||||
/** Message types/categories */
|
||||
export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system";
|
||||
|
||||
/** Message record stored in the system */
|
||||
export interface Message {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Sender identifier */
|
||||
fromId: string;
|
||||
/** Sender type */
|
||||
fromType: ParticipantType;
|
||||
/** Recipient identifier */
|
||||
toId: string;
|
||||
/** Recipient type */
|
||||
toType: ParticipantType;
|
||||
/** Message body */
|
||||
content: string;
|
||||
/** Message category */
|
||||
type: MessageType;
|
||||
/** Whether the recipient has read this message */
|
||||
read: boolean;
|
||||
/** Optional extra data */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new message */
|
||||
export interface MessageCreateInput {
|
||||
/** Sender identifier (auto-filled by the transport layer if omitted) */
|
||||
fromId?: string;
|
||||
/** Sender type (auto-filled by the transport layer if omitted) */
|
||||
fromType?: ParticipantType;
|
||||
/** Recipient identifier */
|
||||
toId: string;
|
||||
/** Recipient type */
|
||||
toType: ParticipantType;
|
||||
/** Message body */
|
||||
content: string;
|
||||
/** Message category */
|
||||
type: MessageType;
|
||||
/** Optional extra data */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Filter options for querying messages */
|
||||
export interface MessageFilter {
|
||||
/** Filter by message type */
|
||||
type?: MessageType;
|
||||
/** Filter by read status */
|
||||
read?: boolean;
|
||||
/** Maximum number of messages to return */
|
||||
limit?: number;
|
||||
/** Number of messages to skip (for pagination) */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Mailbox summary for a participant */
|
||||
export interface Mailbox {
|
||||
/** Owner identifier */
|
||||
ownerId: string;
|
||||
/** Owner type */
|
||||
ownerType: ParticipantType;
|
||||
/** Number of unread messages */
|
||||
unreadCount: number;
|
||||
/** Most recent message (if any) */
|
||||
lastMessage?: Message;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user