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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user