Files
fusion/packages/engine/src/__tests__/ntfy-provider.test.ts
gsxdsm 5930c18b4d FN-5795: send ntfy notification when agents create tasks
Add agent task-created ntfy notifications with configurable settings and coverage.

- add notification plumbing to emit a dedicated event when an agent creates a task
- extend ntfy provider/settings typing and dashboard settings coverage for the new event toggle
- add regression tests for notification service, notifier integration, and ntfy provider behavior
- add a changeset and documentation updates describing the new notification capability

Files changed:
 .changeset/fn-5795-task-created-notification.md    |  9 +++
 docs/architecture.md                               |  4 +-
 docs/settings-reference.md                         |  4 +-
 docs/storage.md                                    |  2 +-
 packages/core/src/types.ts                         |  4 +-
 .../dashboard/app/components/SettingsModal.tsx     |  1 +
 .../components/__tests__/SettingsModal.test.tsx    | 27 +++++++-
 .../src/__tests__/notification-service.test.ts     | 71 ++++++++++++++++++++++
 packages/engine/src/__tests__/notifier.test.ts     |  5 ++
 .../engine/src/__tests__/ntfy-provider.test.ts     | 32 ++++++++++
 .../src/notification/notification-service.ts       | 53 +++++++++++++++-
 packages/engine/src/notification/ntfy-provider.ts  | 21 +++++--
 12 files changed, 220 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-5795

Fusion-Task-Lineage: 4cfa3b46-8e9c-451e-ab2d-b7d5c9bf5968
2026-05-31 19:59:32 -07:00

267 lines
9.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
sendNtfyNotificationWithResult: vi.fn(async () => ({ ok: true, status: 200, statusText: 'OK' })),
buildNtfyClickUrl: vi.fn(() => "http://dash/?project=p1&task=FN-1"),
}));
vi.mock("../notifier.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../notifier.js")>();
return {
...actual,
sendNtfyNotificationWithResult: mocks.sendNtfyNotificationWithResult,
buildNtfyClickUrl: mocks.buildNtfyClickUrl,
};
});
import { NtfyNotificationProvider, resolveParticipantLabel } from "../notification/ntfy-provider.js";
describe("NtfyNotificationProvider", () => {
let provider: NtfyNotificationProvider;
beforeEach(async () => {
mocks.sendNtfyNotificationWithResult.mockClear();
mocks.buildNtfyClickUrl.mockClear();
provider = new NtfyNotificationProvider();
await provider.initialize({
topic: "topic-a",
ntfyBaseUrl: "https://ntfy.local",
ntfyAccessToken: "secret-token",
dashboardHost: "http://dash",
projectId: "p1",
events: [
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"fallback-used",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
"oauth-token-expired",
"task-created",
],
});
});
it("returns provider id", () => {
expect(provider.getProviderId()).toBe("ntfy");
});
it.each([
["in-review", "Task FN-1 completed", "is ready for review", "default"],
["merged", "Task FN-1 merged", "has been merged to main", "default"],
["failed", "Task FN-1 failed", "has failed and needs attention", "high"],
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
["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"],
["task-created", "New task FN-1 created by agent", "Triage Bot created \"T\"", "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"],
["message:room", "#Incident Room — Triage Bot", "Triage Bot in #Incident Room: preview text", "default"],
["oauth-token-expired", "OAuth token expired", "Your OpenAI Codex OAuth token has expired", "high"],
])("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",
fromName: "Triage Bot",
toName: "Executor Bot",
senderAgentId: "agent-1",
senderName: "Triage Bot",
preview: "preview text",
messageId: "msg-1",
roomId: "room-1",
roomName: "Incident Room",
providerId: "openai-codex",
providerName: "OpenAI Codex",
agentName: "Triage Bot",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
topic: "topic-a",
ntfyAccessToken: "secret-token",
title: expectedTitle,
priority,
message: expect.stringContaining(messagePart),
}),
);
});
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);
expect(provider.isEventSupported("failed" as any)).toBe(true);
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
expect(provider.isEventSupported("task-created" 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("oauth-token-expired" as any)).toBe(true);
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
});
it("shutdown aborts internal AbortController", async () => {
await provider.shutdown();
await provider.sendNotification("in-review" as any, { taskId: "FN-1", taskTitle: "T", event: "in-review" as any });
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(expect.objectContaining({ signal: undefined }));
});
it("uses fallback identifier from id+description when no title", async () => {
await provider.sendNotification("failed" as any, {
taskId: "FN-1",
taskDescription: "desc",
event: "failed" as any,
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Task "FN-1: desc"') }),
);
});
it("builds click URL from config for task message events", async () => {
await provider.sendNotification("message:agent-to-user" as any, {
taskId: "FN-1",
taskTitle: "T",
event: "message:agent-to-user" as any,
metadata: { messageId: "msg-1", fromId: "agent-1", preview: "hello" },
});
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith({
dashboardHost: "http://dash",
projectId: "p1",
taskId: "FN-1",
messageId: "msg-1",
view: "mailbox",
});
});
it("uses task deep link for task-created notifications", async () => {
await provider.sendNotification("task-created" as any, {
taskId: "FN-1",
taskTitle: "T",
event: "task-created" as any,
metadata: { sourceAgentId: "agent-1", agentName: "Triage Bot" },
});
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith({
dashboardHost: "http://dash",
projectId: "p1",
taskId: "FN-1",
});
});
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,
metadata: { messageId: "msg-2", fromId: "agent-1", toId: "agent-2", preview: "hello" },
});
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
expect.objectContaining({
taskId: undefined,
messageId: "msg-2",
view: "mailbox",
}),
);
});
it("passes unicode mailbox content through verbatim for agent-to-user notifications", async () => {
await provider.sendNotification("message:agent-to-user" as any, {
taskId: "FN-1",
taskTitle: "T",
event: "message:agent-to-user" as any,
metadata: {
messageId: "msg-1",
fromId: "agent-1",
fromName: "Triage Bot",
preview: "preview text",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
title: "New message from Triage Bot",
message: "Triage Bot → you: preview text",
}),
);
});
it("passes unicode mailbox content through verbatim for agent-to-agent notifications", async () => {
await provider.sendNotification("message:agent-to-agent" as any, {
event: "message:agent-to-agent" as any,
metadata: {
messageId: "msg-2",
fromId: "agent-1",
toId: "agent-2",
fromName: "Triage Bot",
toName: "Executor Bot",
preview: "preview text",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
title: "Triage Bot → Executor Bot",
message: "Triage Bot messaged Executor Bot: preview text",
}),
);
});
it("uses Re: title for agent-to-agent replies", async () => {
await provider.sendNotification("message:agent-to-agent" as any, {
event: "message:agent-to-agent" as any,
metadata: {
messageId: "msg-3",
fromId: "agent-1",
toId: "agent-2",
preview: "reply preview",
replyToMessageId: "msg-1",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
title: "Re: reply preview",
}),
);
});
});