feat(FN-3484): normalize dashboard mailbox and user identity for messaging

This merge normalizes dashboard user identity and mailbox messaging (FN-3484, 4 steps), adds workflow step execution for plugins (FN-3490), and updates the restart integration store mock for plugin templates (FN-3096). Core changes touch the message store and store modules with identity normalizatio

Fusion-Task-Id: FN-3484
This commit is contained in:
Fusion
2026-05-05 06:27:05 -07:00
committed by gsxdsm
parent 0097212dd9
commit 59f6c8426e
11 changed files with 248 additions and 54 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix dashboard user mailbox routing to use deterministic canonical identity normalization so agent replies sent to `dashboard`, `user:dashboard`, or `User: user:dashboard` all land in the dashboard inbox while preserving reply-link metadata.

View File

@@ -545,6 +545,17 @@ Mailbox replies use `message.metadata.replyTo.messageId` as the stable reply lin
The dashboard mailbox UI also uses the same metadata contract when users click **Reply**, so user and agent replies share one threading model. The dashboard mailbox UI also uses the same metadata contract when users click **Reply**, so user and agent replies share one threading model.
### Dashboard user recipient convention
For dashboard user messaging, agents should target the canonical user recipient ID `dashboard`.
Runtime safeguards defensively normalize the legacy alias forms below to the same logical dashboard user:
- `dashboard` (canonical)
- `user:dashboard`
- `User: user:dashboard`
This normalization applies on send and mailbox reads, so replies from agents still land in the dashboard inbox even when older alias-like recipient strings appear.
### How It Works ### How It Works
1. **Message Prefetch**: When `messageStore` is available, heartbeat runs fetch up to 10 unread inbox messages for the agent. 1. **Message Prefetch**: When `messageStore` is available, heartbeat runs fetch up to 10 unread inbox messages for the agent.

View File

@@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { Database } from "../db.js"; import { Database } from "../db.js";
import { MessageStore } from "../message-store.js"; import { MessageStore } from "../message-store.js";
import { DASHBOARD_USER_ID } from "../types.js";
import type { Message, Mailbox } from "../types.js"; import type { Message, Mailbox } from "../types.js";
describe("MessageStore", () => { describe("MessageStore", () => {
@@ -123,6 +124,40 @@ describe("MessageStore", () => {
const result = store.getMessage("msg-nonexistent"); const result = store.getMessage("msg-nonexistent");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it.each(["dashboard", "user:dashboard", "User: user:dashboard"])(
"canonicalizes dashboard user alias '%s' when writing recipient",
(dashboardAlias) => {
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: dashboardAlias,
toType: "user",
content: "Hello dashboard",
type: "agent-to-user",
});
expect(message.toId).toBe(DASHBOARD_USER_ID);
expect(store.getMessage(message.id)?.toId).toBe(DASHBOARD_USER_ID);
},
);
it.each(["dashboard", "user:dashboard", "User: user:dashboard"])(
"canonicalizes dashboard user alias '%s' when writing sender",
(dashboardAlias) => {
const message = store.sendMessage({
fromId: dashboardAlias,
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Reply",
type: "user-to-agent",
});
expect(message.fromId).toBe(DASHBOARD_USER_ID);
expect(store.getMessage(message.id)?.fromId).toBe(DASHBOARD_USER_ID);
},
);
}); });
describe("message-to-agent hook", () => { describe("message-to-agent hook", () => {
@@ -234,6 +269,15 @@ describe("MessageStore", () => {
expect(inbox).toEqual([]); expect(inbox).toEqual([]);
}); });
it("includes legacy dashboard aliases in canonical dashboard inbox reads", () => {
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user", content: "A", type: "agent-to-user" });
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "user:dashboard", toType: "user", content: "B", type: "agent-to-user" });
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "User: user:dashboard", toType: "user", content: "C", type: "agent-to-user" });
const inbox = store.getInbox(DASHBOARD_USER_ID, "user");
expect(inbox).toHaveLength(3);
});
it("filters by read status", () => { it("filters by read status", () => {
const msg1 = store.sendMessage({ const msg1 = store.sendMessage({
fromId: "agent-1", fromId: "agent-1",
@@ -418,6 +462,14 @@ describe("MessageStore", () => {
const count = store.markAllAsRead("user-99", "user"); const count = store.markAllAsRead("user-99", "user");
expect(count).toBe(0); expect(count).toBe(0);
}); });
it("marks canonical dashboard aliases as read together", () => {
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user", content: "A", type: "agent-to-user" });
store.sendMessage({ fromId: "agent-2", fromType: "agent", toId: "user:dashboard", toType: "user", content: "B", type: "agent-to-user" });
const marked = store.markAllAsRead(DASHBOARD_USER_ID, "user");
expect(marked).toBe(2);
expect(store.getMailbox(DASHBOARD_USER_ID, "user").unreadCount).toBe(0);
});
}); });
describe("deleteMessage()", () => { describe("deleteMessage()", () => {
@@ -524,6 +576,31 @@ describe("MessageStore", () => {
); );
expect(conversation).toEqual([]); expect(conversation).toEqual([]);
}); });
it("treats canonical dashboard identity as equivalent to legacy aliases in conversation reads", () => {
const sent = store.sendMessage({
fromId: "dashboard",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Question",
type: "user-to-agent",
});
const reply = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user:dashboard",
toType: "user",
content: "Answer",
type: "agent-to-user",
});
const conversation = store.getConversation(
{ id: DASHBOARD_USER_ID, type: "user" },
{ id: "agent-1", type: "agent" },
);
expect(conversation.map((message) => message.id)).toEqual([sent.id, reply.id]);
});
}); });
describe("getMailbox()", () => { describe("getMailbox()", () => {
@@ -561,6 +638,15 @@ describe("MessageStore", () => {
expect(mailbox.lastMessage).toBeUndefined(); expect(mailbox.lastMessage).toBeUndefined();
}); });
it("aggregates unread count across canonical and legacy dashboard aliases", () => {
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user", content: "A", type: "agent-to-user" });
store.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "User: user:dashboard", toType: "user", content: "B", type: "agent-to-user" });
const mailbox = store.getMailbox(DASHBOARD_USER_ID, "user");
expect(mailbox.unreadCount).toBe(2);
expect(mailbox.lastMessage).toBeTruthy();
});
it("counts only unread messages", () => { it("counts only unread messages", () => {
const msg1 = store.sendMessage({ const msg1 = store.sendMessage({
fromId: "agent-1", fromId: "agent-1",

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js"; export * from "./mesh-replication-protocol.js";

View File

@@ -14,7 +14,7 @@ import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Database } from "./db.js"; import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js"; import { fromJson, toJsonNullable } from "./db.js";
import { validateMessageMetadata, type Message, type MessageCreateInput, type MessageFilter, type MessageType, type Mailbox, type ParticipantType } from "./types.js"; import { DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, type Message, type MessageCreateInput, type MessageFilter, type MessageType, type Mailbox, type ParticipantType } from "./types.js";
// ── Event Types ───────────────────────────────────────────────────── // ── Event Types ─────────────────────────────────────────────────────
@@ -69,8 +69,6 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
private stmtGetById!: ReturnType<Database["prepare"]>; private stmtGetById!: ReturnType<Database["prepare"]>;
private stmtUpdateRead!: ReturnType<Database["prepare"]>; private stmtUpdateRead!: ReturnType<Database["prepare"]>;
private stmtDelete!: ReturnType<Database["prepare"]>; private stmtDelete!: ReturnType<Database["prepare"]>;
private stmtCountUnread!: ReturnType<Database["prepare"]>;
private stmtGetLastMessage!: ReturnType<Database["prepare"]>;
constructor( constructor(
private db: Database, private db: Database,
@@ -97,14 +95,6 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
this.stmtDelete = this.db.prepare(` this.stmtDelete = this.db.prepare(`
DELETE FROM messages WHERE id = ? DELETE FROM messages WHERE id = ?
`); `);
this.stmtCountUnread = this.db.prepare(`
SELECT COUNT(*) as count FROM messages WHERE toId = ? AND toType = ? AND read = 0
`);
this.stmtGetLastMessage = this.db.prepare(`
SELECT * FROM messages WHERE toId = ? AND toType = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1
`);
} }
// ── Row-to-Object Converters ─────────────────────────────────────── // ── Row-to-Object Converters ───────────────────────────────────────
@@ -141,15 +131,15 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const messageId = `msg-${randomUUID().slice(0, 8)}`; const messageId = `msg-${randomUUID().slice(0, 8)}`;
const fromId = input.fromId ?? "system"; const from = normalizeMessageParticipant(input.fromId ?? "system", input.fromType ?? "system");
const fromType = input.fromType ?? "system"; const to = normalizeMessageParticipant(input.toId, input.toType);
const message: Message = { const message: Message = {
id: messageId, id: messageId,
fromId, fromId: from.id,
fromType, fromType: from.type,
toId: input.toId, toId: to.id,
toType: input.toType, toType: to.type,
content: input.content, content: input.content,
type: input.type, type: input.type,
read: false, read: false,
@@ -224,6 +214,13 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
return this.queryMessagesByParticipant("from", ownerId, ownerType, filter); return this.queryMessagesByParticipant("from", ownerId, ownerType, filter);
} }
private getParticipantIdsForLookup(ownerId: string, ownerType: ParticipantType): string[] {
if (ownerType === "user" && ownerId === DASHBOARD_USER_ID) {
return [DASHBOARD_USER_ID, "user:dashboard", "User: user:dashboard"];
}
return [ownerId];
}
private queryMessagesByParticipant( private queryMessagesByParticipant(
direction: "to" | "from", direction: "to" | "from",
ownerId: string, ownerId: string,
@@ -232,8 +229,12 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
): Message[] { ): Message[] {
const idCol = direction === "to" ? "toId" : "fromId"; const idCol = direction === "to" ? "toId" : "fromId";
const typeCol = direction === "to" ? "toType" : "fromType"; const typeCol = direction === "to" ? "toType" : "fromType";
const whereClauses: string[] = [`${idCol} = ?`, `${typeCol} = ?`]; const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const params: (string | number)[] = [ownerId, ownerType]; const idPredicate = participantIds.length === 1
? `${idCol} = ?`
: `${idCol} IN (${participantIds.map(() => "?").join(", ")})`;
const whereClauses: string[] = [idPredicate, `${typeCol} = ?`];
const params: (string | number)[] = [...participantIds, ownerType];
if (filter?.type) { if (filter?.type) {
whereClauses.push("type = ?"); whereClauses.push("type = ?");
@@ -294,16 +295,21 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
ownerType: ParticipantType, ownerType: ParticipantType,
): number { ): number {
const now = new Date().toISOString(); const now = new Date().toISOString();
const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const toIdPredicate = participantIds.length === 1
? "toId = ?"
: `toId IN (${participantIds.map(() => "?").join(", ")})`;
// Get count of unread messages before updating // Get count of unread messages before updating
const unreadRow = this.db.prepare(` const unreadRow = this.db.prepare(`
SELECT COUNT(*) as count FROM messages WHERE toId = ? AND toType = ? AND read = 0 SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).get(ownerId, ownerType) as { count: number } | undefined; `).get(...participantIds, ownerType) as { count: number } | undefined;
const count = unreadRow?.count ?? 0; const count = unreadRow?.count ?? 0;
// Mark all as read // Mark all as read
this.db.prepare(` this.db.prepare(`
UPDATE messages SET read = 1, updatedAt = ? WHERE toId = ? AND toType = ? AND read = 0 UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).run(now, ownerId, ownerType); `).run(now, ...participantIds, ownerType);
this.db.bumpLastModified(); this.db.bumpLastModified();
return count; return count;
@@ -336,21 +342,39 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
participantA: { id: string; type: ParticipantType }, participantA: { id: string; type: ParticipantType },
participantB: { id: string; type: ParticipantType }, participantB: { id: string; type: ParticipantType },
): Message[] { ): Message[] {
const participantAIds = this.getParticipantIdsForLookup(participantA.id, participantA.type);
const participantBIds = this.getParticipantIdsForLookup(participantB.id, participantB.type);
const participantAFromPredicate = participantAIds.length === 1
? "fromId = ?"
: `fromId IN (${participantAIds.map(() => "?").join(", ")})`;
const participantAToPredicate = participantAIds.length === 1
? "toId = ?"
: `toId IN (${participantAIds.map(() => "?").join(", ")})`;
const participantBFromPredicate = participantBIds.length === 1
? "fromId = ?"
: `fromId IN (${participantBIds.map(() => "?").join(", ")})`;
const participantBToPredicate = participantBIds.length === 1
? "toId = ?"
: `toId IN (${participantBIds.map(() => "?").join(", ")})`;
// Find messages where either participant is sender or receiver // Find messages where either participant is sender or receiver
// This captures all messages between the two participants
const rows = this.db.prepare(` const rows = this.db.prepare(`
SELECT * FROM messages SELECT * FROM messages
WHERE ( WHERE (
(fromId = ? AND fromType = ? AND toId = ? AND toType = ?) (${participantAFromPredicate} AND fromType = ? AND ${participantBToPredicate} AND toType = ?)
OR OR
(fromId = ? AND fromType = ? AND toId = ? AND toType = ?) (${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?)
) )
ORDER BY createdAt ASC ORDER BY createdAt ASC
`).all( `).all(
participantA.id, participantA.type, ...participantAIds,
participantB.id, participantB.type, participantA.type,
participantB.id, participantB.type, ...participantBIds,
participantA.id, participantA.type, participantB.type,
...participantBIds,
participantB.type,
...participantAIds,
participantA.type,
); );
return (rows as unknown as MessageRow[]).map((row) => this.rowToMessage(row)); return (rows as unknown as MessageRow[]).map((row) => this.rowToMessage(row));
@@ -366,10 +390,19 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
ownerId: string, ownerId: string,
ownerType: ParticipantType, ownerType: ParticipantType,
): Mailbox { ): Mailbox {
const unreadRow = this.stmtCountUnread.get(ownerId, ownerType) as { count: number } | undefined; const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const toIdPredicate = participantIds.length === 1
? "toId = ?"
: `toId IN (${participantIds.map(() => "?").join(", ")})`;
const unreadRow = this.db.prepare(`
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).get(...participantIds, ownerType) as { count: number } | undefined;
const unreadCount = unreadRow?.count ?? 0; const unreadCount = unreadRow?.count ?? 0;
const lastRow = this.stmtGetLastMessage.get(ownerId, ownerType) as unknown as MessageRow | undefined; const lastRow = this.db.prepare(`
SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1
`).get(...participantIds, ownerType) as unknown as MessageRow | undefined;
const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined; const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined;
return { return {

View File

@@ -4117,6 +4117,24 @@ export interface MigrationResult {
/** Participant types for message routing */ /** Participant types for message routing */
export type ParticipantType = "agent" | "user" | "system"; export type ParticipantType = "agent" | "user" | "system";
/** Canonical recipient ID for dashboard user mailbox routing. */
export const DASHBOARD_USER_ID = "dashboard";
const DASHBOARD_USER_ALIASES = new Set([DASHBOARD_USER_ID, "user:dashboard", "User: user:dashboard"]);
/** Normalize participant identity for durable mailbox routing. */
export function normalizeMessageParticipant(id: string, type: ParticipantType): { id: string; type: ParticipantType } {
if (type !== "user") {
return { id, type };
}
if (DASHBOARD_USER_ALIASES.has(id)) {
return { id: DASHBOARD_USER_ID, type };
}
return { id, type };
}
/** Message types/categories */ /** Message types/categories */
export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system"; export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system";

View File

@@ -3130,6 +3130,23 @@ describe("Messaging Routes", () => {
expect(res.body.unreadCount).toBe(1); expect(res.body.unreadCount).toBe(1);
}); });
it("dashboard inbox aggregates legacy dashboard user aliases", async () => {
messageStore.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "dashboard", toType: "user", content: "A", type: "agent-to-user" });
messageStore.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "user:dashboard", toType: "user", content: "B", type: "agent-to-user" });
messageStore.sendMessage({ fromId: "agent-1", fromType: "agent", toId: "User: user:dashboard", toType: "user", content: "C", type: "agent-to-user" });
const inbox = await GET(app, "/api/messages/inbox");
expect(inbox.status).toBe(200);
expect(inbox.body.messages).toHaveLength(3);
const unread = await GET(app, "/api/messages/unread-count");
expect(unread.body.unreadCount).toBe(3);
const readAll = await REQUEST(app, "POST", "/api/messages/read-all");
expect(readAll.status).toBe(200);
expect(readAll.body.markedAsRead).toBe(3);
});
it("GET /api/messages/outbox returns dashboard sent messages", async () => { it("GET /api/messages/outbox returns dashboard sent messages", async () => {
const sent = await REQUEST( const sent = await REQUEST(
app, app,

View File

@@ -1,5 +1,5 @@
import type { Request } from "express"; import type { Request } from "express";
import { MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core"; import { DASHBOARD_USER_ID, MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, notFound } from "../api-error.js";
import { getTerminalService } from "../terminal-service.js"; import { getTerminalService } from "../terminal-service.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
@@ -190,7 +190,6 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"]; const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"];
const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"]; const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"];
const DASHBOARD_USER_ID = "dashboard";
router.get("/messages/inbox", async (req, res) => { router.get("/messages/inbox", async (req, res) => {
try { try {

View File

@@ -727,6 +727,24 @@ describe("createSendMessageTool", () => {
); );
}); });
it.each(["dashboard", "user:dashboard", "User: user:dashboard"])(
"canonicalizes dashboard alias '%s' for agent-to-user sends",
async (dashboardAlias) => {
const mockMessage = createMessage({ toId: "dashboard", toType: "user", type: "agent-to-user" });
vi.mocked(messageStore.sendMessage).mockReturnValue(mockMessage);
await executeTool(tool, {
to_id: dashboardAlias,
content: "Status",
type: "agent-to-user",
});
expect(messageStore.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ toId: "dashboard", toType: "user", type: "agent-to-user" }),
);
},
);
it("maps recipient type to agent for agent-to-agent messages", async () => { it("maps recipient type to agent for agent-to-agent messages", async () => {
const mockMessage = createMessage({ toType: "agent", type: "agent-to-agent" }); const mockMessage = createMessage({ toType: "agent", type: "agent-to-agent" });
vi.mocked(messageStore.sendMessage).mockReturnValue(mockMessage); vi.mocked(messageStore.sendMessage).mockReturnValue(mockMessage);

View File

@@ -1607,11 +1607,12 @@ describe("executeHeartbeat", () => {
const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "fn_send_message"); const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "fn_send_message");
expect(sendMessageTool).toBeDefined(); expect(sendMessageTool).toBeDefined();
for (const [index, alias] of ["dashboard", "user:dashboard", "User: user:dashboard"].entries()) {
await sendMessageTool!.execute( await sendMessageTool!.execute(
"tool-call", `tool-call-${index}`,
{ {
to_id: "dashboard", to_id: alias,
content: "Status: I am on it.", content: `Status: I am on it. (${index})`,
type: "agent-to-user", type: "agent-to-user",
reply_to_message_id: inboundFromUser.id, reply_to_message_id: inboundFromUser.id,
}, },
@@ -1619,10 +1620,13 @@ describe("executeHeartbeat", () => {
undefined, undefined,
{} as any, {} as any,
); );
}
const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user"); const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user");
const linkedReply = dashboardInbox.find((message) => message.content === "Status: I am on it."); for (const index of [0, 1, 2]) {
const linkedReply = dashboardInbox.find((message) => message.content === `Status: I am on it. (${index})`);
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } }); expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
}
await monitor.stop(); await monitor.stop();
}); });

View File

@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path"; import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore } from "@fusion/core"; import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core"; import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js"; import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js"; import { ResearchStepRunner } from "./research-step-runner.js";
@@ -1432,7 +1432,10 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
try { try {
const messageType = params.type ?? "agent-to-agent"; const messageType = params.type ?? "agent-to-agent";
const recipientType = messageType === "agent-to-user" ? "user" : "agent"; const recipientType: "user" | "agent" = messageType === "agent-to-user" ? "user" : "agent";
const recipient = recipientType === "user"
? normalizeMessageParticipant(params.to_id, recipientType)
: { id: params.to_id, type: recipientType };
const replyToMessageId = params.reply_to_message_id?.trim(); const replyToMessageId = params.reply_to_message_id?.trim();
if (params.reply_to_message_id !== undefined && !replyToMessageId) { if (params.reply_to_message_id !== undefined && !replyToMessageId) {
@@ -1445,8 +1448,8 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
const message = messageStore.sendMessage({ const message = messageStore.sendMessage({
fromId: fromAgentId, fromId: fromAgentId,
fromType: "agent", fromType: "agent",
toId: params.to_id, toId: recipient.id,
toType: recipientType, toType: recipient.type,
content, content,
type: messageType, type: messageType,
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}), ...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
@@ -1455,7 +1458,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: `Message sent to ${params.to_id} (ID: ${message.id})`, text: `Message sent to ${recipient.id === DASHBOARD_USER_ID ? DASHBOARD_USER_ID : params.to_id} (ID: ${message.id})`,
}], }],
details: { messageId: message.id }, details: { messageId: message.id },
}; };