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. |
|
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ describe("NotificationDispatcher", () => {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"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