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:
Fusion
2026-05-08 22:08:31 -07:00
committed by gsxdsm
parent 12ddca0dfe
commit 12bad74955
22 changed files with 608 additions and 22 deletions

View File

@@ -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" }));

View File

@@ -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", () => {

View File

@@ -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",
}),
);
});
});

View File

@@ -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" });