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:
9
.changeset/fn-5795-task-created-notification.md
Normal file
9
.changeset/fn-5795-task-created-notification.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a new opt-in `task-created` notification event for ntfy/webhook providers.
|
||||
|
||||
- `task-created` fires when a task is created by an agent (`sourceAgentId` present), including agent-issued `fn_task_create` calls.
|
||||
- Event is off by default and must be explicitly enabled in Settings → Notifications (`ntfyEvents` / provider `events`).
|
||||
- ntfy formatting includes agent attribution and task deep-linking to the created task.
|
||||
@@ -722,12 +722,12 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
|
||||
- `[scheduler]`, `[executor]`, `[merger]` — core execution/dispatch/merge lanes.
|
||||
- `[insight-sweeper]` — startup/periodic/drive-by stale insight-run recovery outcomes and fail-soft sweep errors.
|
||||
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
|
||||
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
|
||||
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:created`, `task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
|
||||
- Merge dedup safety: all merge-success → done code paths (direct merger completion, owned/no-op auto-finalize, mergeConfirmed fast-path, PR-strategy finalize, and merge-success self-healing finalizers) emit `store.emit("task:merged", result)` with a merged `MergeResult`. `NotificationService.notifiedEvents` remains the single dedup source of truth, so duplicate upstream emits still produce exactly one canonical `merged` ntfy lifecycle notification per task.
|
||||
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
|
||||
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
|
||||
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
||||
- Subscribes to task lifecycle events plus mailbox and memory events. `message:sent` dispatches `message:agent-to-user` and `message:agent-to-agent` notification events (with message metadata for deep-links), and manual `POST /api/memory/dream` processing emits `store.emit("memory:dreams-processed", payload)` when new DREAMS content is written.
|
||||
- Subscribes to task lifecycle events plus mailbox and memory events. `task:created` dispatches `task-created` only when `task.sourceAgentId` is present (agent-created tasks, including fn task-create calls made by agents). `message:sent` dispatches `message:agent-to-user` and `message:agent-to-agent` notification events (with message metadata for deep-links), and manual `POST /api/memory/dream` processing emits `store.emit("memory:dreams-processed", payload)` when new DREAMS content is written.
|
||||
- `failed` task notifications are deferred behind a grace window (default 60s) and suppressed when recovery signals arrive (`column=done`, `mergeDetails.mergeConfirmed=true`, or status clear with an `Auto-recovered:` log). Persistent failures still emit exactly once after the window.
|
||||
- `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract
|
||||
- Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`)
|
||||
|
||||
@@ -45,7 +45,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `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. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | 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). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `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). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | 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). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `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). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||
| `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. |
|
||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||
@@ -161,7 +161,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired. |
|
||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired. |
|
||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||
|
||||
Disable daily update checks globally:
|
||||
|
||||
@@ -212,7 +212,7 @@ API endpoints reviewed:
|
||||
| `ntfyTopic` | Global | `GET/PUT /api/settings/global` | Ntfy topic |
|
||||
| `ntfyBaseUrl` | Global | `GET/PUT /api/settings/global` | Custom ntfy server base URL override |
|
||||
| `ntfyAccessToken` | Global | `GET/PUT /api/settings/global` | Access token for authenticated ntfy publishes |
|
||||
| `ntfyEvents` | Global | `GET/PUT /api/settings/global` | Notification event filters |
|
||||
| `ntfyEvents` | Global | `GET/PUT /api/settings/global` | Notification event filters (includes opt-in `task-created` for agent-created task notifications) |
|
||||
| `ntfyDashboardHost` | Global | `GET/PUT /api/settings/global` | Host for deep links |
|
||||
| `defaultProjectId` | Global | `GET/PUT /api/settings/global` | CLI default project |
|
||||
| `setupComplete` | Global | `GET/PUT /api/settings/global` (internal first-run use) | Setup wizard completion flag |
|
||||
|
||||
@@ -511,7 +511,8 @@ export type NtfyNotificationEvent =
|
||||
| "message:agent-to-user"
|
||||
| "message:agent-to-agent"
|
||||
| "message:room"
|
||||
| "oauth-token-expired";
|
||||
| "oauth-token-expired"
|
||||
| "task-created";
|
||||
|
||||
/** Known notification event types. Providers may support additional custom events. */
|
||||
export const NOTIFICATION_EVENTS = [
|
||||
@@ -531,6 +532,7 @@ export const NOTIFICATION_EVENTS = [
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
"oauth-token-expired",
|
||||
"task-created",
|
||||
] as const;
|
||||
|
||||
/** Notification event type. Known events plus provider-specific custom events. */
|
||||
|
||||
@@ -315,6 +315,7 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
|
||||
{ event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" },
|
||||
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
||||
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
||||
{ event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" },
|
||||
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
||||
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
||||
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
||||
|
||||
@@ -4362,26 +4362,51 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("shows fallback, dreams, and mailbox/room message events for both providers", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
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",
|
||||
"message:room",
|
||||
"oauth-token-expired",
|
||||
],
|
||||
});
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
expect(screen.getByLabelText("Fallback model used (recovered)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Agent created a task")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
||||
const taskCreatedNtfy = screen.getByLabelText("Agent created a task") as HTMLInputElement;
|
||||
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
||||
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
||||
const roomMessageNtfy = screen.getByLabelText("Agent message in room") as HTMLInputElement;
|
||||
expect(taskCreatedNtfy.checked).toBe(false);
|
||||
expect(agentToUserNtfy.checked).toBe(true);
|
||||
expect(agentToAgentNtfy.checked).toBe(true);
|
||||
expect(roomMessageNtfy.checked).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
||||
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("Agent created a task").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
||||
const [taskCreatedWebhook] = screen.getAllByLabelText("Agent created a task") as HTMLInputElement[];
|
||||
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
||||
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
||||
const [roomMessageWebhook] = screen.getAllByLabelText("Agent message in room") as HTMLInputElement[];
|
||||
expect(taskCreatedWebhook.checked).toBe(false);
|
||||
expect(agentToUserWebhook.checked).toBe(true);
|
||||
expect(agentToAgentWebhook.checked).toBe(true);
|
||||
expect(roomMessageWebhook.checked).toBe(true);
|
||||
|
||||
@@ -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