feat(FN-4178): add room message notification delivery

This merge implements room message notifications across the system, adding a core room event type, wiring the notification dispatcher to room activity, and delivering notifications via ntfy and webhook providers with updated settings UI and API routes.

Fusion-Task-Id: FN-4178
This commit is contained in:
Fusion
2026-05-12 13:25:29 -07:00
committed by gsxdsm
parent 8d4835998c
commit 044b1cba2f
20 changed files with 531 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a new `message:room` notification event for agent assistant replies posted in chat rooms. The event is enabled by default for ntfy notifications and can be tested or toggled from Settings → Notifications alongside the existing direct-message events.

View File

@@ -43,11 +43,11 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). If you use a custom `ntfyEvents` list, this event must be present (or `ntfyEvents` must be unset so defaults apply) for agent-to-agent inbox notifications to send. |
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
In **Settings → Notifications**, use **Test message notification** to exercise the full mailbox-message dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
In **Settings → Notifications**, use **Test message notification** or **Send test room notification** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
Fusion automatically falls back to ntfy's JSON publish format when a notification title or message contains non-Latin-1 characters, and truncates outgoing titles/messages to ntfy's documented size limits before sending.
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
@@ -148,7 +148,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
| `topic` | `string` | _required_ | ntfy topic name (164 chars, alphanumeric + `-_`). |
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. |
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. |
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
Disable daily update checks globally:

View File

@@ -344,6 +344,7 @@ describe("GlobalSettingsStore", () => {
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -154,6 +154,7 @@ describe("NotificationDispatcher", () => {
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -33,6 +33,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -258,7 +258,8 @@ export type NtfyNotificationEvent =
| "fallback-used"
| "memory-dreams-processed"
| "message:agent-to-user"
| "message:agent-to-agent";
| "message:agent-to-agent"
| "message:room";
/** Known notification event types. Providers may support additional custom events. */
export const NOTIFICATION_EVENTS = [
@@ -273,6 +274,7 @@ export const NOTIFICATION_EVENTS = [
"memory-dreams-processed",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
] as const;
/** Notification event type. Known events plus provider-specific custom events. */

View File

@@ -248,6 +248,7 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
"memory-dreams-processed",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
];
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
@@ -262,6 +263,7 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
{ event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" },
];
/** Well-known experimental feature flags with display labels.
@@ -1168,8 +1170,8 @@ export function SettingsModal({
}
}, [addToast, loadAuthStatus]);
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message") => {
if (providerId === "ntfy" || providerId === "ntfy-message") {
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message" | "ntfy-room") => {
if (providerId === "ntfy" || providerId === "ntfy-message" || providerId === "ntfy-room") {
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
return;
}
@@ -1205,15 +1207,21 @@ export function SettingsModal({
}
: providerId === "ntfy-message"
? { messageEventType: "message:agent-to-user" }
: {
webhookUrl: form.webhookUrl,
webhookFormat: form.webhookFormat || "generic",
};
const result = await testNotification(providerId === "ntfy-message" ? "ntfy" : providerId, config, projectId);
: providerId === "ntfy-room"
? { messageEventType: "message:room" }
: {
webhookUrl: form.webhookUrl,
webhookFormat: form.webhookFormat || "generic",
};
const result = await testNotification(
providerId === "ntfy-message" || providerId === "ntfy-room" ? "ntfy" : providerId,
config,
projectId,
);
if (result.success) {
const providerName = providerId === "ntfy"
? "ntfy app"
: providerId === "ntfy-message"
: providerId === "ntfy-message" || providerId === "ntfy-room"
? "ntfy app inbox"
: "webhook endpoint";
const successMessage = `Test notification sent — check your ${providerName}!`;
@@ -4987,6 +4995,7 @@ export function SettingsModal({
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
@@ -5001,6 +5010,7 @@ export function SettingsModal({
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
@@ -5008,8 +5018,23 @@ export function SettingsModal({
>
{testNotificationLoading["ntfy-message"] ? "Sending…" : "Test message notification"}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => handleTestProviderNotification("ntfy-room")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy-room"] ? "Sending…" : "Send test room notification"}
</button>
</div>
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"]) && (
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["ntfy"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
@@ -5021,6 +5046,11 @@ export function SettingsModal({
{testNotificationResult["ntfy-message"].message}
</small>
)}
{testNotificationResult["ntfy-room"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
{testNotificationResult["ntfy-room"].message}
</small>
)}
</div>
)}
</div>

View File

@@ -3071,7 +3071,7 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
});
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
it("shows fallback, dreams, and mailbox/room message events for both providers", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
renderModal();
await waitForSettingsModalReady();
@@ -3081,16 +3081,20 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
const roomMessageNtfy = screen.getByLabelText("Agent message in room") as HTMLInputElement;
expect(agentToUserNtfy.checked).toBe(true);
expect(agentToAgentNtfy.checked).toBe(true);
expect(roomMessageNtfy.checked).toBe(true);
await userEvent.click(screen.getByLabelText("Webhook notifications"));
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
const [roomMessageWebhook] = screen.getAllByLabelText("Agent message in room") as HTMLInputElement[];
expect(agentToUserWebhook.checked).toBe(true);
expect(agentToAgentWebhook.checked).toBe(true);
expect(roomMessageWebhook.checked).toBe(true);
});
it("shows webhook fields when webhook provider is enabled", async () => {
@@ -3187,6 +3191,28 @@ describe("SettingsModal", () => {
expect(screen.getAllByText("Test notification sent — check your ntfy app inbox!")[0].closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
});
it("calls testNotification with ntfy room-event config when room test button clicked", async () => {
const addToast = vi.fn();
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
renderModal({ addToast });
await waitForSettingsModalReady();
await openNotificationsSection();
await userEvent.click(screen.getByRole("button", { name: /Send test room notification/ }));
await waitFor(() => {
expect(mockTestNotification).toHaveBeenCalledWith(
"ntfy",
{ messageEventType: "message:room" },
undefined,
);
});
expect(addToast).toHaveBeenCalledWith(
"Test notification sent — check your ntfy app inbox!",
"success",
);
});
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
renderModal();
await waitForSettingsModalReady();

View File

@@ -1872,6 +1872,39 @@ describe("POST /settings/test-notification", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(dispatchSpy).toHaveBeenCalledWith(
"message:room",
expect.objectContaining({
event: "message:room",
metadata: expect.objectContaining({
roomId: "test-room",
roomName: "Test Room",
senderName: "Fusion",
preview: "Fusion test room notification",
}),
}),
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("ntfy provider uses config override for baseUrl", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,

View File

@@ -1939,8 +1939,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
if (
requestedMessageEventType !== "message:agent-to-user"
&& requestedMessageEventType !== "message:agent-to-agent"
&& requestedMessageEventType !== "message:room"
) {
throw badRequest("messageEventType must be message:agent-to-user or message:agent-to-agent");
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
}
const notificationService = getActiveNotificationService();
@@ -1949,21 +1950,39 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
try {
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
await notificationService.dispatch(requestedMessageEventType, {
taskId: undefined,
taskTitle: undefined,
event: requestedMessageEventType,
metadata: {
messageId: `test-${crypto.randomUUID()}`,
fromId: "system",
fromType: "agent",
toId: "user",
toType: "user",
type: messageType,
preview: "Fusion test message notification",
},
});
const messageId = `test-${crypto.randomUUID()}`;
if (requestedMessageEventType === "message:room") {
await notificationService.dispatch(requestedMessageEventType, {
taskId: undefined,
taskTitle: undefined,
event: requestedMessageEventType,
metadata: {
messageId,
roomId: "test-room",
roomName: "Test Room",
senderAgentId: "system",
senderName: "Fusion",
preview: "Fusion test room notification",
type: "room-assistant",
},
});
} else {
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
await notificationService.dispatch(requestedMessageEventType, {
taskId: undefined,
taskTitle: undefined,
event: requestedMessageEventType,
metadata: {
messageId,
fromId: "system",
fromType: "agent",
toId: "user",
toType: "user",
type: messageType,
preview: "Fusion test message notification",
},
});
}
res.json({ success: true });
return;
} catch (error) {

View File

@@ -610,6 +610,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Create ChatStore for chat session management (available for SSE event forwarding)
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
options?.engine?.attachChatStore?.(chatStore);
if (typeof options?.engineManager?.getAllEngines === "function") {
for (const engine of options.engineManager.getAllEngines().values()) {
engine.attachChatStore?.(chatStore);
}
}
// Lets the browser explicitly release server-side SSE listeners during page
// unload. EventSource.close() is not enough in Chrome refresh paths because

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Message, NotificationProvider, Settings, Task } from "@fusion/core";
import type { ChatRoomMessage, Message, NotificationProvider, Settings, Task } from "@fusion/core";
import { NotificationService } from "../notification/notification-service.js";
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
import { schedulerLog } from "../logger.js";
@@ -65,6 +65,22 @@ function createMessage(overrides: Partial<Message> = {}): Message {
} as Message;
}
function createRoomMessage(overrides: Partial<ChatRoomMessage> = {}): ChatRoomMessage {
return {
id: "rmsg-1",
roomId: "room-1",
role: "assistant",
content: "hello from room agent",
thinkingOutput: null,
metadata: null,
attachments: [],
senderAgentId: "agent-1",
mentions: [],
createdAt: new Date().toISOString(),
...overrides,
};
}
describe("NotificationService", () => {
it("dispatches in-review event to registered provider", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
@@ -263,6 +279,98 @@ describe("NotificationService", () => {
);
});
it("dispatches message:room from chat:room:message:added", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const chatStore = new EventEmitter() as EventEmitter & {
getRoom: (id: string) => { id: string; name: string } | undefined;
};
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const service = new NotificationService(store as any, {
chatStore: chatStore as any,
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : null),
});
service.registerProvider(provider);
await service.start();
chatStore.emit("chat:room:message:added", createRoomMessage());
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
expect(sendNotification).toHaveBeenCalledWith(
"message:room",
expect.objectContaining({
event: "message:room",
metadata: expect.objectContaining({
messageId: "rmsg-1",
roomId: "room-1",
roomName: "Incident Room",
senderAgentId: "agent-1",
senderName: "Triage Bot",
preview: "hello from room agent",
type: "room-assistant",
}),
}),
);
});
it("dispatches room notifications when chat store attaches after start", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const chatStore = new EventEmitter() as EventEmitter & {
getRoom: (id: string) => { id: string; name: string } | undefined;
};
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const service = new NotificationService(store as any, {
agentNameResolver: () => "Triage Bot",
});
service.registerProvider(provider);
await service.start();
service.attachChatStore(chatStore as any);
chatStore.emit("chat:room:message:added", createRoomMessage());
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledWith(
"message:room",
expect.objectContaining({ event: "message:room" }),
);
});
});
it("ignores non-agent or non-assistant room messages", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const chatStore = 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, { chatStore: chatStore as any });
service.registerProvider(provider);
await service.start();
chatStore.emit("chat:room:message:added", createRoomMessage({ role: "user" }));
chatStore.emit("chat:room:message:added", createRoomMessage({ id: "rmsg-2", senderAgentId: null }));
await Promise.resolve();
expect(sendNotification).not.toHaveBeenCalled();
});
it("dispatches even when agent name resolution fails", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const messageStore = new EventEmitter();

View File

@@ -73,6 +73,7 @@ describe("Ntfy notifier helpers", () => {
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
expect(DEFAULT_NTFY_EVENTS).toContain("message:room");
});
it("checks planning-awaiting-input event enablement", () => {
@@ -106,6 +107,18 @@ describe("Ntfy notifier helpers", () => {
}),
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
});
it("builds room message deep links", () => {
expect(
buildNtfyClickUrl({
dashboardHost: "http://localhost:4040/",
projectId: "proj-1",
roomId: "room-1",
messageId: "msg-1",
view: "rooms",
}),
).toBe("http://localhost:4040/?project=proj-1&view=rooms&room=room-1#message-msg-1");
});
});
describe("sendNtfyNotificationWithResult", () => {

View File

@@ -46,6 +46,7 @@ describe("NtfyNotificationProvider", () => {
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
["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"],
["message:room", "#Incident Room — Triage Bot", "Triage Bot in #Incident Room: preview text", "default"],
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
await provider.sendNotification(event as any, {
taskId: "FN-1",
@@ -56,8 +57,12 @@ describe("NtfyNotificationProvider", () => {
toId: "agent-2",
fromName: "Triage Bot",
toName: "Executor Bot",
senderAgentId: "agent-1",
senderName: "Triage Bot",
preview: "preview text",
messageId: "msg-1",
roomId: "room-1",
roomName: "Incident Room",
},
});
@@ -87,6 +92,7 @@ describe("NtfyNotificationProvider", () => {
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
expect(provider.isEventSupported("message:room" as any)).toBe(true);
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
});
@@ -124,6 +130,28 @@ describe("NtfyNotificationProvider", () => {
});
});
it("uses room deep link for room notifications", async () => {
await provider.sendNotification("message:room" as any, {
event: "message:room" as any,
metadata: {
messageId: "msg-room",
roomId: "room-1",
roomName: "Incident Room",
senderAgentId: "agent-1",
senderName: "Triage Bot",
preview: "hello",
},
});
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
expect.objectContaining({
roomId: "room-1",
messageId: "msg-room",
view: "rooms",
}),
);
});
it("uses mailbox deep link when message is not task-bound", async () => {
await provider.sendNotification("message:agent-to-agent" as any, {
event: "message:agent-to-agent" as any,

View File

@@ -167,6 +167,7 @@ describe("WebhookNotificationProvider", () => {
["fallback-used", "Fusion recovered by switching from"],
["message:agent-to-user", "From: Triage Bot → You: hello"],
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
["message:room", "In #Incident Room: Triage 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" });
@@ -176,7 +177,17 @@ describe("WebhookNotificationProvider", () => {
taskId: "FN-1",
taskTitle: "My Task",
event,
metadata: { fromId: "agent-1", toId: "agent-2", fromName: "Triage Bot", toName: "Executor Bot", preview: "hello" },
metadata: {
fromId: "agent-1",
toId: "agent-2",
fromName: "Triage Bot",
toName: "Executor Bot",
senderAgentId: "agent-1",
senderName: "Triage Bot",
roomId: "room-1",
roomName: "Incident Room",
preview: "hello",
},
});
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
@@ -184,6 +195,40 @@ describe("WebhookNotificationProvider", () => {
expect(body.text).toContain(expectedPart);
});
it("includes room metadata and room deep link for room notifications", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({
webhookUrl: "https://example.com/hook",
webhookFormat: "generic",
dashboardHost: "http://dash",
projectId: "p1",
});
await provider.sendNotification("message:room", {
event: "message:room",
metadata: {
messageId: "msg-room",
roomId: "room-1",
roomName: "Incident Room",
senderAgentId: "agent-1",
senderName: "Triage Bot",
preview: "hello",
},
});
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const payload = JSON.parse(String(requestInit.body));
expect(payload.metadata).toEqual(
expect.objectContaining({
roomId: "room-1",
roomName: "Incident Room",
senderAgentId: "agent-1",
senderName: "Triage Bot",
}),
);
expect(payload.clickUrl).toBe("http://dash/?project=p1&view=rooms&room=room-1#message-msg-room");
});
it("title fallback uses taskId and truncated description snippet", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });

View File

@@ -1,4 +1,5 @@
import type {
ChatRoomMessage,
Column,
MergeResult,
Message,
@@ -21,6 +22,8 @@ export interface NotificationServiceOptions {
ntfyBaseUrl?: string;
/** Optional message store for mailbox message notifications */
messageStore?: NotificationMessageStore;
/** Optional chat store for room message notifications */
chatStore?: NotificationChatStore;
/** Resolve human-readable name for an agent ID used in message notifications */
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
}
@@ -36,10 +39,17 @@ interface NotificationMessageStore {
off?(event: "message:sent", listener: (message: Message) => void): void;
}
export interface NotificationChatStore {
on(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
off?(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
getRoom?(id: string): { id: string; name: string } | undefined;
}
export class NotificationService {
private readonly dispatcher = new NotificationDispatcher();
private readonly notifiedEvents = new Set<string>();
private started = false;
private chatStore: NotificationChatStore | undefined;
private notificationsEnabled = false;
private ntfyProvider?: NtfyNotificationProvider;
private webhookProvider?: WebhookNotificationProvider;
@@ -48,7 +58,19 @@ export class NotificationService {
constructor(
private readonly store: NotificationServiceStore,
private readonly options: NotificationServiceOptions = {},
) {}
) {
this.chatStore = options.chatStore;
}
attachChatStore(chatStore: NotificationChatStore): void {
if (this.chatStore && this.chatStore !== chatStore) {
this.detachChatStoreListener(this.chatStore);
}
this.chatStore = chatStore;
if (this.started) {
this.chatStore.on("chat:room:message:added", this.handleRoomMessageAdded);
}
}
registerProvider(provider: NotificationProvider): void {
this.dispatcher.registerProvider(provider);
@@ -71,8 +93,8 @@ export class NotificationService {
this.store.on("task:merged", this.handleTaskMerged);
this.store.on("settings:updated", this.handleSettingsUpdated);
this.options.messageStore?.on("message:sent", this.handleMessageSent);
this.started = true;
this.chatStore?.on("chat:room:message:added", this.handleRoomMessageAdded);
schedulerLog.log("NotificationService started");
}
@@ -89,6 +111,7 @@ export class NotificationService {
if (typeof this.options.messageStore?.off === "function") {
this.options.messageStore.off("message:sent", this.handleMessageSent);
}
this.detachChatStoreListener(this.chatStore);
}
await this.dispatcher.shutdownAll();
@@ -246,6 +269,10 @@ export class NotificationService {
void this.handleMessageSentAsync(message);
};
private handleRoomMessageAdded = (message: ChatRoomMessage): void => {
void this.handleRoomMessageAddedAsync(message);
};
private async handleMessageSentAsync(message: Message): Promise<void> {
schedulerLog.log(
`NotificationService.handleMessageSent messageId=${message.id} type=${message.type} notificationsEnabled=${String(this.notificationsEnabled)} hasNtfyProvider=${String(Boolean(this.ntfyProvider))}`,
@@ -267,9 +294,7 @@ export class NotificationService {
return;
}
const preview = message.content.length > 100
? `${message.content.slice(0, 100)}`
: message.content;
const preview = this.createPreview(message.content);
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
@@ -299,6 +324,44 @@ export class NotificationService {
);
}
private async handleRoomMessageAddedAsync(message: ChatRoomMessage): Promise<void> {
schedulerLog.log(
`NotificationService.handleRoomMessageAdded messageId=${message.id} roomId=${message.roomId} role=${message.role} notificationsEnabled=${String(this.notificationsEnabled)}`,
);
if (message.role !== "assistant" || message.senderAgentId == null) {
return;
}
if (!this.notificationsEnabled) {
await this.refreshNotificationState("chat:room:message:added");
if (!this.notificationsEnabled) {
return;
}
}
const senderName = await this.resolveAgentName("agent", message.senderAgentId, "from");
const roomName = this.chatStore?.getRoom?.(message.roomId)?.name;
const preview = this.createPreview(message.content);
this.maybeNotify(message.id, "message:room", {
event: "message:room",
metadata: {
messageId: message.id,
roomId: message.roomId,
...(roomName ? { roomName } : {}),
senderAgentId: message.senderAgentId,
...(senderName ? { senderName } : {}),
preview,
type: "room-assistant",
},
});
schedulerLog.log(
`NotificationService.handleRoomMessageAdded scheduled eventType=message:room messageId=${message.id}`,
);
}
private async resolveAgentName(
participantType: Message["fromType"],
participantId: string,
@@ -326,6 +389,16 @@ export class NotificationService {
}
}
private createPreview(content: string): string {
return content.length > 100 ? `${content.slice(0, 100)}` : content;
}
private detachChatStoreListener(chatStore: NotificationChatStore | undefined): void {
if (typeof chatStore?.off === "function") {
chatStore.off("chat:room:message:added", this.handleRoomMessageAdded);
}
}
private setNotificationsEnabledFromSettings(settings: Settings): void {
this.notificationsEnabled = Boolean(
(settings.ntfyEnabled && settings.ntfyTopic) ||

View File

@@ -39,7 +39,8 @@ type SupportedNtfyEvent =
| "planning-awaiting-input"
| "fallback-used"
| "message:agent-to-user"
| "message:agent-to-agent";
| "message:agent-to-agent"
| "message:room";
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"in-review",
@@ -51,6 +52,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"fallback-used",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
]);
export function resolveParticipantLabel(
@@ -67,6 +69,24 @@ export function resolveParticipantLabel(
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
}
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
if (senderName.length > 0) {
return senderName;
}
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
return senderAgentId.length > 0 ? senderAgentId : "agent";
}
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
if (roomName.length > 0) {
return roomName;
}
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
return roomId.length > 0 ? roomId : "room";
}
export class NtfyNotificationProvider implements NotificationProvider {
private config?: NtfyProviderConfig;
private abortController: AbortController | null = null;
@@ -137,14 +157,25 @@ export class NtfyNotificationProvider implements NotificationProvider {
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
? payload.metadata.replyToMessageId
: undefined;
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
const roomLabel = resolveRoomLabel(payload.metadata);
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
const clickUrl = buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
messageId,
view: "mailbox",
});
const clickUrl = event === "message:room"
? buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
roomId,
messageId,
view: "rooms",
})
: buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
messageId,
view: "mailbox",
});
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
"in-review": {
@@ -192,6 +223,11 @@ export class NtfyNotificationProvider implements NotificationProvider {
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
priority: "default",
},
"message:room": {
title: `#${roomLabel}${roomSenderLabel}`,
message: `${roomSenderLabel} in #${roomLabel}: ${preview}`,
priority: "default",
},
};
const content = contentByEvent[event as SupportedNtfyEvent];

View File

@@ -34,6 +34,24 @@ function resolveParticipantLabel(
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
}
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
if (senderName.length > 0) {
return senderName;
}
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
return senderAgentId.length > 0 ? senderAgentId : "agent";
}
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
if (roomName.length > 0) {
return roomName;
}
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
return roomId.length > 0 ? roomId : "room";
}
export class WebhookNotificationProvider implements NotificationProvider {
private config: WebhookProviderConfig | null = null;
private abortController: AbortController | null = null;
@@ -167,6 +185,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
return `From: ${from} → To: ${to}: ${preview}`;
}
case "message:room": {
const roomName = resolveRoomLabel(payload.metadata);
const senderLabel = resolveRoomSenderLabel(payload.metadata);
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
return `In #${roomName}: ${senderLabel}: ${preview}`;
}
default:
return `Event "${event}" for task ${identifier}`;
}
@@ -196,9 +220,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
}
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
const toLabel = resolveParticipantLabel(payload.metadata, "to");
const roomLabel = resolveRoomLabel(payload.metadata);
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
return {
event: payload.event,
@@ -215,14 +242,28 @@ export class WebhookNotificationProvider implements NotificationProvider {
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
}
: {}),
...(payload.event === "message:room"
? {
roomName: typeof payload.metadata?.roomName === "string" ? payload.metadata.roomName : roomLabel,
senderName: typeof payload.metadata?.senderName === "string" ? payload.metadata.senderName : roomSenderLabel,
}
: {}),
},
clickUrl: buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
messageId,
view: "mailbox",
}),
clickUrl: payload.event === "message:room"
? buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
roomId,
messageId,
view: "rooms",
})
: buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
messageId,
view: "mailbox",
}),
};
}
}

View File

@@ -31,6 +31,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"fallback-used",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
] as const;
export interface NtfyNotificationConfigInput {
@@ -150,9 +151,10 @@ export function buildNtfyClickUrl(options: {
projectId?: string;
taskId?: string;
messageId?: string;
roomId?: string;
view?: string;
}): string | undefined {
const { dashboardHost, projectId, taskId, messageId, view } = options;
const { dashboardHost, projectId, taskId, messageId, roomId, view } = options;
if (!dashboardHost) {
return undefined;
}
@@ -165,6 +167,9 @@ export function buildNtfyClickUrl(options: {
}
if (taskId) {
queryParts.push(`task=${encodeURIComponent(taskId)}`);
} else if (roomId) {
queryParts.push(`view=${encodeURIComponent(view ?? "rooms")}`);
queryParts.push(`room=${encodeURIComponent(roomId)}`);
} else if (messageId) {
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);

View File

@@ -19,6 +19,7 @@ import { PrMonitor } from "./pr-monitor.js";
import { PrCommentHandler } from "./pr-comment-handler.js";
import { NtfyNotifier } from "./notifier.js";
import { NotificationService } from "./notification/index.js";
import type { NotificationChatStore } from "./notification/notification-service.js";
import { GridlockDetector } from "./gridlock-detector.js";
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js";
@@ -601,6 +602,10 @@ export class ProjectEngine {
return this.runtime.getMessageStore();
}
attachChatStore(chatStore: NotificationChatStore): void {
this.notificationService?.attachChatStore(chatStore);
}
/** Get the HeartbeatMonitor (if initialized). */
getHeartbeatMonitor() {
return this.runtime.getHeartbeatMonitor();