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:
5
.changeset/fn-3887-agent-to-agent-notifications.md
Normal file
5
.changeset/fn-3887-agent-to-agent-notifications.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user