docs(FN-3887): clarify ntfy allowlist requirement for agent messages

- Update settings reference language for ntfy integration
- Clarify that agent messages require allowlisting configuration
- Keep notification setup guidance aligned with current behavior

Fusion-Task-Id: FN-3887
This commit is contained in:
Fusion
2026-05-09 14:03:57 -07:00
committed by gsxdsm
parent 13a3e15367
commit 2864f70608
6 changed files with 167 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Backfill global ntfy default events to include `message:agent-to-user` and `message:agent-to-agent` so mailbox notifications are enabled by default for new settings files.

View File

@@ -42,7 +42,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
| `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. |
| `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). |
| `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. |
| `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. |

View File

@@ -324,6 +324,8 @@ describe("GlobalSettingsStore", () => {
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -152,6 +152,8 @@ describe("NotificationDispatcher", () => {
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -30,6 +30,8 @@ export const DEFAULT_GLOBAL_SETTINGS = {
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"message:agent-to-user",
"message:agent-to-agent",
"gridlock",
"fallback-used",
"memory-dreams-processed",

View File

@@ -0,0 +1,155 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Database, MessageStore, TaskStore } from "@fusion/core";
import { NotificationService } from "../notification/notification-service.js";
function makeTempRoot(): string {
return mkdtempSync(join(tmpdir(), "fn-msg-notify-pipeline-"));
}
describe("message notification pipeline integration", () => {
let rootDir: string;
let taskStore: TaskStore;
let messageDb: Database;
let messageStore: MessageStore;
let service: NotificationService;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(async () => {
rootDir = makeTempRoot();
taskStore = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await taskStore.init();
messageDb = new Database(join(rootDir, ".fusion"), { inMemory: true });
messageDb.init();
messageStore = new MessageStore(messageDb);
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
service = new NotificationService(taskStore, { messageStore });
await service.start();
});
afterEach(async () => {
await service.stop();
vi.unstubAllGlobals();
messageDb.close();
taskStore.close();
rmSync(rootDir, { recursive: true, force: true });
});
it("sends and suppresses agent-to-agent notifications according to runtime ntfy settings", async () => {
await taskStore.updateGlobalSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"],
});
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "agent-B",
toType: "agent",
content: "hi from agent A",
type: "agent-to-agent",
});
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
const firstOptions = fetchSpy.mock.calls[0]?.[1] as RequestInit;
const firstHeaders = firstOptions.headers as Record<string, string>;
expect(firstHeaders.Title).toBe("agent-A → agent-B");
expect(String(firstOptions.body)).toContain("agent-A messaged agent-B: hi from agent A");
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "agent-B",
toType: "agent",
content: "reply preview",
type: "agent-to-agent",
metadata: { replyTo: { messageId: "msg-origin" } },
});
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
const replyHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
expect(replyHeaders.Title).toBe("Re: reply preview");
await taskStore.updateGlobalSettings({ ntfyEvents: ["message:agent-to-user"] });
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "agent-B",
toType: "agent",
content: "should be filtered",
type: "agent-to-agent",
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchSpy).toHaveBeenCalledTimes(2);
await taskStore.updateGlobalSettings({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "agent-B",
toType: "agent",
content: "enabled again",
type: "agent-to-agent",
});
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
await taskStore.updateGlobalSettings({ ntfyEnabled: false });
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "agent-B",
toType: "agent",
content: "disabled should suppress",
type: "agent-to-agent",
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it("keeps agent-to-user notifications working", async () => {
await taskStore.updateGlobalSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"],
});
messageStore.sendMessage({
fromId: "agent-A",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "hello user",
type: "agent-to-user",
});
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
const request = fetchSpy.mock.calls[0]?.[1] as RequestInit;
const headers = request.headers as Record<string, string>;
expect(headers.Title).toBe("New message from agent-A");
expect(String(request.body)).toContain("agent-A → you: hello user");
});
});