fix(FN-3886): resolve peer agent names in mail and notifications

- Resolve participant display names in AgentDetailView mail tab for peer agents
- Update MailboxModal labeling to use readable agent names instead of raw IDs
- Propagate resolved agent names through notification service, notifier, and provider payloads
- Add and update dashboard/engine tests plus a changeset for @runfusion/fusion

Fusion-Task-Id: FN-3886
This commit is contained in:
Fusion
2026-05-09 14:58:53 -07:00
committed by gsxdsm
parent 76e6eedec0
commit f9cba2507c
14 changed files with 396 additions and 37 deletions

View File

@@ -40,7 +40,10 @@ describe("message notification pipeline", () => {
const store = createStore({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
const messageStore = new TestMessageStore();
const service = new NotificationService(store as any, { messageStore: messageStore as any });
const service = new NotificationService(store as any, {
messageStore: messageStore as any,
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : "Executor Bot"),
});
await service.start();
messageStore.sendMessage("agent-to-user", "hi");
@@ -48,6 +51,8 @@ describe("message notification pipeline", () => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
const firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
expect(firstHeaders.Title).toContain("Triage Bot");
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
expect(firstBody).toContain("hi");
@@ -56,6 +61,8 @@ describe("message notification pipeline", () => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
const secondHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
expect(secondHeaders.Title).toContain("Triage Bot → Executor Bot");
const secondBody = String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body);
expect(secondBody).toContain("relay");

View File

@@ -165,7 +165,9 @@ describe("NotificationService", () => {
await service.start();
messageStore.emit("message:sent", createMessage());
await Promise.resolve();
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
expect(sendNotification).toHaveBeenCalledWith(
"message:agent-to-user",
@@ -181,6 +183,74 @@ describe("NotificationService", () => {
);
});
it("includes resolved agent names in message metadata", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const messageStore = new EventEmitter();
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const service = new NotificationService(store as any, {
messageStore: messageStore as any,
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : "Executor Bot"),
});
service.registerProvider(provider);
await service.start();
messageStore.emit(
"message:sent",
createMessage({ type: "agent-to-agent", toId: "agent-2", toType: "agent" }),
);
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
expect(sendNotification).toHaveBeenCalledWith(
"message:agent-to-agent",
expect.objectContaining({
metadata: expect.objectContaining({ fromName: "Triage Bot", toName: "Executor Bot" }),
}),
);
});
it("dispatches even when agent name resolution fails", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const messageStore = new EventEmitter();
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const service = new NotificationService(store as any, {
messageStore: messageStore as any,
agentNameResolver: () => {
throw new Error("boom");
},
});
service.registerProvider(provider);
await service.start();
messageStore.emit("message:sent", createMessage({ type: "agent-to-user" }));
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
expect(sendNotification).toHaveBeenCalledWith(
"message:agent-to-user",
expect.objectContaining({
metadata: expect.not.objectContaining({ fromName: expect.any(String), toName: expect.any(String) }),
}),
);
expect(schedulerLog.log).toHaveBeenCalledWith(
expect.stringContaining("failed to resolve from agent name"),
);
});
it("dispatches message:agent-to-agent with reply metadata", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const messageStore = new EventEmitter();
@@ -205,7 +275,9 @@ describe("NotificationService", () => {
metadata: { replyTo: { messageId: "msg-1" } },
}),
);
await Promise.resolve();
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
expect(sendNotification).toHaveBeenCalledWith(
"message:agent-to-agent",

View File

@@ -14,7 +14,7 @@ vi.mock("../notifier.js", async (importOriginal) => {
};
});
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
import { NtfyNotificationProvider, resolveParticipantLabel } from "../notification/ntfy-provider.js";
describe("NtfyNotificationProvider", () => {
let provider: NtfyNotificationProvider;
@@ -43,14 +43,21 @@ describe("NtfyNotificationProvider", () => {
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
["message:agent-to-user", "New message from agent-1", "agent-1 → you: preview text", "high"],
["message:agent-to-agent", "agent-1 → agent-2", "agent-1 messaged agent-2: preview text", "default"],
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"],
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
await provider.sendNotification(event as any, {
taskId: "FN-1",
taskTitle: "T",
event: event as any,
metadata: { fromId: "agent-1", toId: "agent-2", preview: "preview text", messageId: "msg-1" },
metadata: {
fromId: "agent-1",
toId: "agent-2",
fromName: "Triage Bot",
toName: "Executor Bot",
preview: "preview text",
messageId: "msg-1",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
@@ -63,6 +70,11 @@ describe("NtfyNotificationProvider", () => {
);
});
it("resolveParticipantLabel prefers names and falls back to ids", () => {
expect(resolveParticipantLabel({ fromName: "Triage Bot", fromId: "agent-1" }, "from")).toBe("Triage Bot");
expect(resolveParticipantLabel({ toId: "agent-2" }, "to")).toBe("agent-2");
});
it("supports known events and rejects unknown", () => {
expect(provider.isEventSupported("in-review" as any)).toBe(true);
expect(provider.isEventSupported("merged" as any)).toBe(true);

View File

@@ -76,7 +76,14 @@ describe("WebhookNotificationProvider", () => {
taskId: "FN-1",
taskTitle: "My Task",
event: "message:agent-to-user",
metadata: { messageId: "msg-1", fromId: "agent-1", toId: "user:dashboard", preview: "hello" },
metadata: {
messageId: "msg-1",
fromId: "agent-1",
toId: "user:dashboard",
fromName: "Triage Bot",
toName: "Dashboard User",
preview: "hello",
},
});
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
@@ -84,7 +91,15 @@ describe("WebhookNotificationProvider", () => {
expect(payload.event).toBe("message:agent-to-user");
expect(payload.timestamp).toEqual(expect.any(String));
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
expect(payload.metadata).toEqual(expect.objectContaining({ messageId: "msg-1" }));
expect(payload.metadata).toEqual(
expect.objectContaining({
messageId: "msg-1",
fromId: "agent-1",
toId: "user:dashboard",
fromName: "Triage Bot",
toName: "Dashboard User",
}),
);
expect(payload.clickUrl).toBe("http://dash/?project=p1&task=FN-1#message-msg-1");
});
@@ -150,14 +165,19 @@ describe("WebhookNotificationProvider", () => {
["planning-awaiting-input", "is awaiting your input during planning"],
["gridlock", "Pipeline gridlocked"],
["fallback-used", "Fusion recovered by switching from"],
["message:agent-to-user", 'Event "message:agent-to-user" for task My Task'],
["message:agent-to-agent", 'Event "message:agent-to-agent" for task My Task'],
["message:agent-to-user", "From: Triage Bot → You: hello"],
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
["unknown-event", 'Event "unknown-event" for task My Task'],
])("message formatting for %s", async (event, expectedPart) => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
await provider.sendNotification(event, { taskId: "FN-1", taskTitle: "My Task", event });
await provider.sendNotification(event, {
taskId: "FN-1",
taskTitle: "My Task",
event,
metadata: { fromId: "agent-1", toId: "agent-2", fromName: "Triage Bot", toName: "Executor Bot", preview: "hello" },
});
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(requestInit.body));

View File

@@ -21,6 +21,8 @@ export interface NotificationServiceOptions {
ntfyBaseUrl?: string;
/** Optional message store for mailbox message notifications */
messageStore?: NotificationMessageStore;
/** Resolve human-readable name for an agent ID used in message notifications */
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
}
interface NotificationServiceStore {
@@ -267,6 +269,9 @@ export class NotificationService {
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
const fromName = await this.resolveAgentName(message.fromType, message.fromId, "from");
const toName = await this.resolveAgentName(message.toType, message.toId, "to");
this.maybeNotify(message.id, eventType, {
taskId,
taskTitle: undefined,
@@ -275,8 +280,10 @@ export class NotificationService {
messageId: message.id,
fromId: message.fromId,
fromType: message.fromType,
...(fromName ? { fromName } : {}),
toId: message.toId,
toType: message.toType,
...(toName ? { toName } : {}),
type: message.type,
replyToMessageId: message.metadata?.replyTo?.messageId,
preview,
@@ -288,6 +295,33 @@ export class NotificationService {
);
}
private async resolveAgentName(
participantType: Message["fromType"],
participantId: string,
direction: "from" | "to",
): Promise<string | null> {
if (participantType !== "agent") {
return null;
}
const resolver = this.options.agentNameResolver;
if (!resolver) {
return null;
}
try {
const resolved = await resolver(participantId);
const trimmed = typeof resolved === "string" ? resolved.trim() : "";
return trimmed.length > 0 ? trimmed : null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.log(
`NotificationService.handleMessageSent failed to resolve ${direction} agent name agentId=${participantId} error=${message}`,
);
return null;
}
}
private setNotificationsEnabledFromSettings(settings: Settings): void {
this.notificationsEnabled = Boolean(
(settings.ntfyEnabled && settings.ntfyTopic) ||

View File

@@ -51,6 +51,20 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"message:agent-to-agent",
]);
export function resolveParticipantLabel(
metadata: NotificationPayload["metadata"] | undefined,
kind: "from" | "to",
): string {
const nameKey = kind === "from" ? "fromName" : "toName";
const idKey = kind === "from" ? "fromId" : "toId";
const name = typeof metadata?.[nameKey] === "string" ? metadata[nameKey].trim() : "";
if (name.length > 0) {
return name;
}
const id = typeof metadata?.[idKey] === "string" ? metadata[idKey].trim() : "";
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
}
export class NtfyNotificationProvider implements NotificationProvider {
private config?: NtfyProviderConfig;
private abortController: AbortController | null = null;
@@ -113,8 +127,8 @@ export class NtfyNotificationProvider implements NotificationProvider {
const identifier = formatTaskIdentifier(taskLike);
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
const senderLabel = typeof payload.metadata?.fromId === "string" ? payload.metadata.fromId : "agent";
const recipientLabel = typeof payload.metadata?.toId === "string" ? payload.metadata.toId : "recipient";
const senderLabel = resolveParticipantLabel(payload.metadata, "from");
const recipientLabel = resolveParticipantLabel(payload.metadata, "to");
const preview = typeof payload.metadata?.preview === "string"
? payload.metadata.preview
: "(no preview)";

View File

@@ -20,6 +20,20 @@ export interface WebhookProviderConfig {
projectId?: string;
}
function resolveParticipantLabel(
metadata: NotificationPayload["metadata"] | undefined,
kind: "from" | "to",
): string {
const nameKey = kind === "from" ? "fromName" : "toName";
const idKey = kind === "from" ? "fromId" : "toId";
const name = typeof metadata?.[nameKey] === "string" ? metadata[nameKey].trim() : "";
if (name.length > 0) {
return name;
}
const id = typeof metadata?.[idKey] === "string" ? metadata[idKey].trim() : "";
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
}
export class WebhookNotificationProvider implements NotificationProvider {
private config: WebhookProviderConfig | null = null;
private abortController: AbortController | null = null;
@@ -142,6 +156,17 @@ export class WebhookNotificationProvider implements NotificationProvider {
return "Pipeline gridlocked";
case "fallback-used":
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
case "message:agent-to-user": {
const from = resolveParticipantLabel(payload.metadata, "from");
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
return `From: ${from} → You: ${preview}`;
}
case "message:agent-to-agent": {
const from = resolveParticipantLabel(payload.metadata, "from");
const to = resolveParticipantLabel(payload.metadata, "to");
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
return `From: ${from} → To: ${to}: ${preview}`;
}
default:
return `Event "${event}" for task ${identifier}`;
}
@@ -172,6 +197,9 @@ export class WebhookNotificationProvider implements NotificationProvider {
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
const toLabel = resolveParticipantLabel(payload.metadata, "to");
return {
event: payload.event,
timestamp: new Date().toISOString(),
@@ -179,7 +207,15 @@ export class WebhookNotificationProvider implements NotificationProvider {
id: payload.taskId,
title: payload.taskTitle,
},
metadata: payload.metadata,
metadata: {
...payload.metadata,
...(payload.event === "message:agent-to-user" || payload.event === "message:agent-to-agent"
? {
fromName: typeof payload.metadata?.fromName === "string" ? payload.metadata.fromName : fromLabel,
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
}
: {}),
},
clickUrl: buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,

View File

@@ -8,6 +8,8 @@ export interface NtfyNotifierOptions {
ntfyBaseUrl?: string;
/** Project identifier for deep links in notifications */
projectId?: string;
/** Resolve human-readable agent names for message notifications */
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
}
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
@@ -249,6 +251,7 @@ export class NtfyNotifier {
this.notificationService = notificationService ?? new NotificationService(store, {
projectId: this.projectId,
ntfyBaseUrl: options.ntfyBaseUrl,
agentNameResolver: options.agentNameResolver,
});
activeNotificationService = this.notificationService;
}

View File

@@ -291,10 +291,20 @@ export class ProjectEngine {
// 3. Initialize notification services (unless caller manages them externally)
if (!this.options.skipNotifier) {
const agentStore = this.runtime.getAgentStore();
const agentNameResolver = agentStore
? async (agentId: string): Promise<string | null> => {
const agent = await agentStore.getAgent(agentId);
const name = typeof agent?.name === "string" ? agent.name.trim() : "";
return name.length > 0 ? name : null;
}
: undefined;
this.notificationService = new NotificationService(store, {
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
messageStore: this.runtime.getMessageStore(),
agentNameResolver,
});
await this.notificationService.start();
@@ -304,6 +314,7 @@ export class ProjectEngine {
{
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
agentNameResolver,
},
this.notificationService,
);