feat(FN-3800): add session switcher to chat header and mobile switcher
This merge delivers five features: a session switcher for the chat header with mobile-aware dropdown styling and proper ARIA state, mailbox notification events with deep-link highlighting to the unread task, a fix for org chart connector endpoints in wide subtrees, a correction to merge finalize so Fusion-Task-Id: FN-3800
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { NotificationProvider, Settings, Task } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||
|
||||
@@ -44,6 +45,22 @@ function task(overrides: Partial<Task> = {}): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "msg-1",
|
||||
fromId: "agent-1",
|
||||
fromType: "agent",
|
||||
toId: "user:dashboard",
|
||||
toType: "user",
|
||||
content: "hello from agent",
|
||||
type: "agent-to-user",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
describe("NotificationService", () => {
|
||||
it("dispatches in-review event to registered provider", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
@@ -129,6 +146,138 @@ describe("NotificationService", () => {
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dispatches message:agent-to-user from message:sent", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
messageStore.emit("message:sent", createMessage());
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendNotification).toHaveBeenCalledWith(
|
||||
"message:agent-to-user",
|
||||
expect.objectContaining({
|
||||
event: "message:agent-to-user",
|
||||
metadata: expect.objectContaining({
|
||||
messageId: "msg-1",
|
||||
fromId: "agent-1",
|
||||
toId: "user:dashboard",
|
||||
preview: "hello from agent",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches message:agent-to-agent with reply metadata", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
messageStore.emit(
|
||||
"message:sent",
|
||||
createMessage({
|
||||
id: "msg-2",
|
||||
type: "agent-to-agent",
|
||||
toId: "agent-2",
|
||||
toType: "agent",
|
||||
metadata: { replyTo: { messageId: "msg-1" } },
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendNotification).toHaveBeenCalledWith(
|
||||
"message:agent-to-agent",
|
||||
expect.objectContaining({
|
||||
event: "message:agent-to-agent",
|
||||
metadata: expect.objectContaining({
|
||||
messageId: "msg-2",
|
||||
replyToMessageId: "msg-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores user-to-agent message:sent events", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
messageStore.emit("message:sent", createMessage({ type: "user-to-agent", fromType: "user", toType: "agent" }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispatch mailbox notifications when disabled", async () => {
|
||||
const store = createStore({ ntfyEnabled: false, webhookEnabled: false });
|
||||
const messageStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
messageStore.emit("message:sent", createMessage());
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stop unsubscribes message:sent listener", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
expect(messageStore.listenerCount("message:sent")).toBeGreaterThan(0);
|
||||
await service.stop();
|
||||
expect(messageStore.listenerCount("message:sent")).toBe(0);
|
||||
|
||||
messageStore.emit("message:sent", createMessage());
|
||||
await Promise.resolve();
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
|
||||
@@ -65,11 +65,13 @@ class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
}
|
||||
|
||||
describe("Ntfy notifier helpers", () => {
|
||||
it("includes planning-awaiting-input in default events", () => {
|
||||
it("includes mailbox message events in default events", () => {
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
|
||||
});
|
||||
|
||||
it("checks planning-awaiting-input event enablement", () => {
|
||||
@@ -82,6 +84,27 @@ describe("Ntfy notifier helpers", () => {
|
||||
"http://localhost:4040/?project=proj-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds task message deep links", () => {
|
||||
expect(
|
||||
buildNtfyClickUrl({
|
||||
dashboardHost: "http://localhost:4040/",
|
||||
projectId: "proj-1",
|
||||
taskId: "FN-1",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).toBe("http://localhost:4040/?project=proj-1&task=FN-1#message-msg-1");
|
||||
});
|
||||
|
||||
it("builds standalone mailbox message deep links", () => {
|
||||
expect(
|
||||
buildNtfyClickUrl({
|
||||
dashboardHost: "http://localhost:4040/",
|
||||
projectId: "proj-1",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NtfyNotifier", () => {
|
||||
|
||||
@@ -43,8 +43,15 @@ 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"],
|
||||
["message:agent-to-user", "New message from agent-1", "agent-1 → you: preview text", "high"],
|
||||
["message:agent-to-agent", "agent-1 → agent-2", "agent-1 messaged agent-2: preview text", "default"],
|
||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||
await provider.sendNotification(event as any, { taskId: "FN-1", taskTitle: "T", event: event as any });
|
||||
await provider.sendNotification(event as any, {
|
||||
taskId: "FN-1",
|
||||
taskTitle: "T",
|
||||
event: event as any,
|
||||
metadata: { fromId: "agent-1", toId: "agent-2", preview: "preview text", messageId: "msg-1" },
|
||||
});
|
||||
|
||||
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -64,6 +71,8 @@ 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("message:agent-to-user" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -85,12 +94,53 @@ describe("NtfyNotificationProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds click URL from config", async () => {
|
||||
await provider.sendNotification("merged" as any, { taskId: "FN-1", taskTitle: "T", event: "merged" as any });
|
||||
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 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("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.sendNtfyNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Re: reply preview",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,15 +65,27 @@ describe("WebhookNotificationProvider", () => {
|
||||
|
||||
it("sendNotification formats Generic correctly", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
await provider.initialize({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
webhookFormat: "generic",
|
||||
dashboardHost: "http://dash",
|
||||
projectId: "p1",
|
||||
});
|
||||
|
||||
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
|
||||
await provider.sendNotification("message:agent-to-user", {
|
||||
taskId: "FN-1",
|
||||
taskTitle: "My Task",
|
||||
event: "message:agent-to-user",
|
||||
metadata: { messageId: "msg-1", fromId: "agent-1", toId: "user:dashboard", preview: "hello" },
|
||||
});
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const payload = JSON.parse(String(requestInit.body));
|
||||
expect(payload.event).toBe("in-review");
|
||||
expect(payload.event).toBe("message:agent-to-user");
|
||||
expect(payload.timestamp).toEqual(expect.any(String));
|
||||
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
||||
expect(payload.metadata).toEqual(expect.objectContaining({ messageId: "msg-1" }));
|
||||
expect(payload.clickUrl).toBe("http://dash/?project=p1&task=FN-1#message-msg-1");
|
||||
});
|
||||
|
||||
it("sendNotification returns success on HTTP 200", async () => {
|
||||
@@ -138,6 +150,8 @@ describe("WebhookNotificationProvider", () => {
|
||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||
["gridlock", "Pipeline gridlocked"],
|
||||
["fallback-used", "Fusion recovered by switching from"],
|
||||
["message:agent-to-user", 'Event "message:agent-to-user" for task My Task'],
|
||||
["message:agent-to-agent", 'Event "message:agent-to-agent" for task My Task'],
|
||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||
])("message formatting for %s", async (event, expectedPart) => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Column,
|
||||
MergeResult,
|
||||
Message,
|
||||
NotificationEvent,
|
||||
NotificationPayload,
|
||||
NotificationProvider,
|
||||
@@ -18,6 +19,8 @@ export interface NotificationServiceOptions {
|
||||
projectId?: string;
|
||||
/** Base URL for ntfy.sh (backward compat with NtfyNotifierOptions) */
|
||||
ntfyBaseUrl?: string;
|
||||
/** Optional message store for mailbox message notifications */
|
||||
messageStore?: NotificationMessageStore;
|
||||
}
|
||||
|
||||
interface NotificationServiceStore {
|
||||
@@ -26,6 +29,11 @@ interface NotificationServiceStore {
|
||||
off(event: string, listener: (...args: any[]) => void): void;
|
||||
}
|
||||
|
||||
interface NotificationMessageStore {
|
||||
on(event: "message:sent", listener: (message: Message) => void): void;
|
||||
off?(event: "message:sent", listener: (message: Message) => void): void;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private readonly dispatcher = new NotificationDispatcher();
|
||||
private readonly notifiedEvents = new Set<string>();
|
||||
@@ -59,6 +67,7 @@ export class NotificationService {
|
||||
this.store.on("task:updated", this.handleTaskUpdated);
|
||||
this.store.on("task:merged", this.handleTaskMerged);
|
||||
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||
this.options.messageStore?.on("message:sent", this.handleMessageSent);
|
||||
|
||||
this.started = true;
|
||||
schedulerLog.log("NotificationService started");
|
||||
@@ -74,6 +83,9 @@ export class NotificationService {
|
||||
this.store.off("task:updated", this.handleTaskUpdated);
|
||||
this.store.off("task:merged", this.handleTaskMerged);
|
||||
this.store.off("settings:updated", this.handleSettingsUpdated);
|
||||
if (typeof this.options.messageStore?.off === "function") {
|
||||
this.options.messageStore.off("message:sent", this.handleMessageSent);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dispatcher.shutdownAll();
|
||||
@@ -218,9 +230,48 @@ export class NotificationService {
|
||||
webhookUrl: settings.webhookUrl,
|
||||
webhookFormat: settings.webhookFormat ?? "generic",
|
||||
events: settings.webhookEvents ?? [],
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
projectId: this.options.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessageSent = (message: Message): void => {
|
||||
if (!this.notificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let eventType: NotificationEvent;
|
||||
if (message.type === "agent-to-user") {
|
||||
eventType = "message:agent-to-user";
|
||||
} else if (message.type === "agent-to-agent") {
|
||||
eventType = "message:agent-to-agent";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = message.content.length > 100
|
||||
? `${message.content.slice(0, 100)}…`
|
||||
: message.content;
|
||||
|
||||
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
||||
|
||||
this.maybeNotify(message.id, eventType, {
|
||||
taskId,
|
||||
taskTitle: undefined,
|
||||
event: eventType,
|
||||
metadata: {
|
||||
messageId: message.id,
|
||||
fromId: message.fromId,
|
||||
fromType: message.fromType,
|
||||
toId: message.toId,
|
||||
toType: message.toType,
|
||||
type: message.type,
|
||||
replyToMessageId: message.metadata?.replyTo?.messageId,
|
||||
preview,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||
this.notificationsEnabled = Boolean(
|
||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||
|
||||
@@ -34,7 +34,9 @@ type SupportedNtfyEvent =
|
||||
| "awaiting-approval"
|
||||
| "awaiting-user-review"
|
||||
| "planning-awaiting-input"
|
||||
| "fallback-used";
|
||||
| "fallback-used"
|
||||
| "message:agent-to-user"
|
||||
| "message:agent-to-agent";
|
||||
|
||||
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"in-review",
|
||||
@@ -44,6 +46,8 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"awaiting-user-review",
|
||||
"planning-awaiting-input",
|
||||
"fallback-used",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
]);
|
||||
|
||||
export class NtfyNotificationProvider implements NotificationProvider {
|
||||
@@ -102,10 +106,22 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
} as Pick<Task, "id" | "title" | "description"> as Task;
|
||||
|
||||
const identifier = formatTaskIdentifier(taskLike);
|
||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||
const senderLabel = typeof payload.metadata?.fromId === "string" ? payload.metadata.fromId : "agent";
|
||||
const recipientLabel = typeof payload.metadata?.toId === "string" ? payload.metadata.toId : "recipient";
|
||||
const preview = typeof payload.metadata?.preview === "string"
|
||||
? payload.metadata.preview
|
||||
: "(no preview)";
|
||||
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
|
||||
? payload.metadata.replyToMessageId
|
||||
: undefined;
|
||||
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
});
|
||||
|
||||
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
||||
@@ -144,6 +160,16 @@ 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",
|
||||
},
|
||||
"message:agent-to-user": {
|
||||
title: `New message from ${senderLabel}`,
|
||||
message: `${senderLabel} → you: ${preview}`,
|
||||
priority: "high",
|
||||
},
|
||||
"message:agent-to-agent": {
|
||||
title: replyToMessageId ? `Re: ${preview}` : `${senderLabel} → ${recipientLabel}`,
|
||||
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const content = contentByEvent[event as SupportedNtfyEvent];
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
NotificationResult,
|
||||
} from "@fusion/core";
|
||||
import { schedulerLog } from "../logger.js";
|
||||
import { buildNtfyClickUrl } from "../notifier.js";
|
||||
|
||||
export interface WebhookProviderConfig {
|
||||
/** Webhook endpoint URL */
|
||||
@@ -13,6 +14,10 @@ export interface WebhookProviderConfig {
|
||||
webhookFormat: "slack" | "discord" | "generic";
|
||||
/** Events to send (empty = all events) */
|
||||
events?: string[];
|
||||
/** Dashboard host for click-through deep links */
|
||||
dashboardHost?: string;
|
||||
/** Project identifier for deep links */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export class WebhookNotificationProvider implements NotificationProvider {
|
||||
@@ -49,6 +54,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
webhookUrl,
|
||||
webhookFormat,
|
||||
events: Array.isArray(config.events) ? config.events.filter((event): event is string => typeof event === "string") : [],
|
||||
dashboardHost: typeof config.dashboardHost === "string" ? config.dashboardHost : undefined,
|
||||
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
|
||||
};
|
||||
|
||||
this.abortController?.abort();
|
||||
@@ -163,6 +170,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
return { content: message };
|
||||
}
|
||||
|
||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||
|
||||
return {
|
||||
event: payload.event,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -171,6 +180,13 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
title: payload.taskTitle,
|
||||
},
|
||||
metadata: payload.metadata,
|
||||
clickUrl: buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||
"planning-awaiting-input",
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
] as const;
|
||||
|
||||
export interface NtfyNotificationConfigInput {
|
||||
@@ -99,8 +101,10 @@ export function buildNtfyClickUrl(options: {
|
||||
dashboardHost?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
view?: string;
|
||||
}): string | undefined {
|
||||
const { dashboardHost, projectId, taskId } = options;
|
||||
const { dashboardHost, projectId, taskId, messageId, view } = options;
|
||||
if (!dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -113,10 +117,19 @@ export function buildNtfyClickUrl(options: {
|
||||
}
|
||||
if (taskId) {
|
||||
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
||||
} else if (messageId) {
|
||||
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
|
||||
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);
|
||||
}
|
||||
|
||||
const query = queryParts.join("&");
|
||||
return query ? `${normalizedHost}/?${query}` : `${normalizedHost}/`;
|
||||
const baseUrl = query ? `${normalizedHost}/?${query}` : `${normalizedHost}/`;
|
||||
|
||||
if (messageId) {
|
||||
return `${baseUrl}#message-${encodeURIComponent(messageId)}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -289,6 +289,7 @@ export class ProjectEngine {
|
||||
this.notificationService = new NotificationService(store, {
|
||||
projectId: this.options.projectId,
|
||||
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
||||
messageStore: this.runtime.getMessageStore(),
|
||||
});
|
||||
await this.notificationService.start();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user