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
This commit is contained in:
gsxdsm
2026-05-31 19:59:32 -07:00
parent fa428a487b
commit 5930c18b4d
12 changed files with 220 additions and 13 deletions

View File

@@ -110,6 +110,77 @@ describe("NotificationService", () => {
);
});
describe("task-created notifications", () => {
it("dispatches exactly once for agent-created tasks when enabled", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic", ntfyEvents: ["task-created"] as any });
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: (agentId) => (agentId === "agent-1" ? "Triage Bot" : null),
});
service.registerProvider(provider);
await service.start();
store.emit("task:created", task({ id: "FN-201", sourceAgentId: "agent-1", sourceType: "agent_heartbeat" as any }));
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledWith(
"task-created",
expect.objectContaining({
taskId: "FN-201",
event: "task-created",
metadata: expect.objectContaining({ sourceAgentId: "agent-1", agentName: "Triage Bot" }),
}),
);
});
});
it("does not dispatch for non-agent task creation", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic", ntfyEvents: ["task-created"] as any });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const service = new NotificationService(store as any);
service.registerProvider({ getProviderId: () => "mock", isEventSupported: () => true, sendNotification });
await service.start();
store.emit("task:created", task({ id: "FN-202", sourceAgentId: undefined }));
await Promise.resolve();
expect(sendNotification).not.toHaveBeenCalled();
});
it("filters task-created when event is disabled", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic", ntfyEvents: ["in-review"] as any });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const service = new NotificationService(store as any);
service.registerProvider({ getProviderId: () => "mock", isEventSupported: (event) => event !== "task-created", sendNotification });
await service.start();
store.emit("task:created", task({ id: "FN-203", sourceAgentId: "agent-1", sourceType: "agent_heartbeat" as any }));
await Promise.resolve();
expect(sendNotification).not.toHaveBeenCalled();
});
it("deduplicates duplicate task:created events for the same task id", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic", ntfyEvents: ["task-created"] as any });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const service = new NotificationService(store as any);
service.registerProvider({ getProviderId: () => "mock", isEventSupported: () => true, sendNotification });
await service.start();
const createdTask = task({ id: "FN-204", sourceAgentId: "agent-1", sourceType: "agent_heartbeat" as any });
store.emit("task:created", createdTask);
store.emit("task:created", createdTask);
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledTimes(1);
});
});
});
it("deduplicates same task+event but not different event types", async () => {
const store = createStore({
ntfyEnabled: true,

View File

@@ -84,6 +84,11 @@ describe("Ntfy notifier helpers", () => {
expect(isNtfyEventEnabled(["failed"], "planning-awaiting-input")).toBe(false);
});
it("supports task-created enablement while keeping it default-off", () => {
expect(isNtfyEventEnabled(["task-created"], "task-created")).toBe(true);
expect(DEFAULT_NTFY_EVENTS).not.toContain("task-created");
});
it("builds project dashboard root links without task id", () => {
expect(buildNtfyClickUrl({ dashboardHost: "http://localhost:4040/", projectId: "proj-1" })).toBe(
"http://localhost:4040/?project=proj-1",

View File

@@ -29,6 +29,20 @@ describe("NtfyNotificationProvider", () => {
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",
],
});
});
@@ -44,6 +58,7 @@ describe("NtfyNotificationProvider", () => {
["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"],
@@ -66,6 +81,7 @@ describe("NtfyNotificationProvider", () => {
roomName: "Incident Room",
providerId: "openai-codex",
providerName: "OpenAI Codex",
agentName: "Triage Bot",
},
});
@@ -93,6 +109,7 @@ describe("NtfyNotificationProvider", () => {
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);
@@ -134,6 +151,21 @@ describe("NtfyNotificationProvider", () => {
});
});
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,

View File

@@ -31,11 +31,27 @@ export interface NotificationServiceOptions {
failedNotificationGraceMs?: number;
}
interface NotificationServiceStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:updated": [task: Task];
"task:merged": [result: MergeResult];
"settings:updated": [payload: { settings: Settings; previous: Settings }];
}
interface NotificationServiceStore {
getSettings(): Promise<Settings> | Settings;
getTask?(id: string): Promise<Task | undefined> | Task | undefined;
on(event: string, listener: (...args: any[]) => void): void;
off(event: string, listener: (...args: any[]) => void): void;
on<K extends keyof NotificationServiceStoreEvents>(
event: K,
listener: (...args: NotificationServiceStoreEvents[K]) => void,
): void;
on(event: string | symbol, listener: (...args: any[]) => void): void;
off<K extends keyof NotificationServiceStoreEvents>(
event: K,
listener: (...args: NotificationServiceStoreEvents[K]) => void,
): void;
off(event: string | symbol, listener: (...args: any[]) => void): void;
}
interface NotificationMessageStore {
@@ -101,6 +117,7 @@ export class NotificationService {
await this.dispatcher.initializeAll();
this.store.on("task:created", this.handleTaskCreated);
this.store.on("task:moved", this.handleTaskMoved);
this.store.on("task:updated", this.handleTaskUpdated);
this.store.on("task:merged", this.handleTaskMerged);
@@ -117,6 +134,7 @@ export class NotificationService {
}
if (typeof this.store.off === "function") {
this.store.off("task:created", this.handleTaskCreated);
this.store.off("task:moved", this.handleTaskMoved);
this.store.off("task:updated", this.handleTaskUpdated);
this.store.off("task:merged", this.handleTaskMerged);
@@ -139,6 +157,37 @@ export class NotificationService {
schedulerLog.log("NotificationService stopped");
}
private handleTaskCreated = (task: Task): void => {
void this.handleTaskCreatedAsync(task);
};
private async handleTaskCreatedAsync(task: Task): Promise<void> {
if (typeof task.sourceAgentId !== "string" || task.sourceAgentId.trim().length === 0) {
return;
}
if (!this.notificationsEnabled) {
await this.refreshNotificationState("task:created");
if (!this.notificationsEnabled) {
return;
}
}
const sourceAgentId = task.sourceAgentId.trim();
const agentName = await this.resolveAgentName("agent", sourceAgentId, "from");
this.maybeNotify(task.id, "task-created", {
taskId: task.id,
taskTitle: task.title,
event: "task-created",
metadata: {
sourceAgentId,
...(agentName ? { agentName } : {}),
sourceType: task.sourceType,
},
});
}
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
void this.handleTaskMovedAsync(data);
};

View File

@@ -38,6 +38,7 @@ type SupportedNtfyEvent =
| "awaiting-user-review"
| "planning-awaiting-input"
| "fallback-used"
| "task-created"
| "message:agent-to-user"
| "message:agent-to-agent"
| "message:room"
@@ -51,6 +52,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"awaiting-user-review",
"planning-awaiting-input",
"fallback-used",
"task-created",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
@@ -171,15 +173,21 @@ export class NtfyNotificationProvider implements NotificationProvider {
messageId,
view: "rooms",
})
: event === "oauth-token-expired"
? undefined
: buildNtfyClickUrl({
: event === "message:agent-to-user" || event === "message:agent-to-agent"
? buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
messageId,
view: "mailbox",
});
})
: event === "oauth-token-expired"
? undefined
: buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
});
const providerId = typeof payload.metadata?.providerId === "string" ? payload.metadata.providerId : "provider";
const providerName = typeof payload.metadata?.providerName === "string"
@@ -222,6 +230,11 @@ export class NtfyNotificationProvider implements NotificationProvider {
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
priority: "high",
},
"task-created": {
title: `New task ${taskId} created by agent`,
message: `${typeof payload.metadata?.agentName === "string" && payload.metadata.agentName.trim().length > 0 ? payload.metadata.agentName.trim() : "An agent"} created "${identifier}"`,
priority: "default",
},
"message:agent-to-user": {
title: `New message from ${senderLabel}`,
message: `${senderLabel} → you: ${preview}`,