import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js"; // The dispatcher imports `loadNtfyConfig` from config.ts, which transitively // pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can // load this file. All tests inject `loadConfig` explicitly, so the real // settings table is never read. vi.mock("../../src/db/index.js", () => ({ getDatabase: vi.fn(() => ({ query: () => ({ get: () => null, run: () => {} }), run: () => {}, })), })); const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js"); function makeConfig(overrides: Partial = {}): NtfyConfig { return { enabled: true, topic: "test-topic", authToken: "", events: { "turn-completed": true, "turn-error": true, "permission-required": true, "agent-spawned": true, }, // Default to true in the test config so existing tests (which never // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc. // Tests of the new subagent gating override this explicitly. notifySubagents: true, ...overrides, }; } interface FakeAgentSource { onEvent( listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void, ): () => void; emit(event: { type: string; tabId: string; [k: string]: unknown }): void; } function makeAgentSource(): FakeAgentSource { let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null; return { onEvent(listener) { l = listener; return () => { l = null; }; }, emit(event) { l?.(event); }, }; } interface FakePermissionSource { onPromptAdded( listener: (prompt: { id: string; permission: string; description: string }) => void, ): () => void; emit(prompt: { id: string; permission: string; description: string }): void; } function makePermissionSource(): FakePermissionSource { let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null; return { onPromptAdded(listener) { l = listener; return () => { l = null; }; }, emit(p) { l?.(p); }, }; } // Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)` // has a chance to settle before assertions. async function flush(): Promise { await Promise.resolve(); await Promise.resolve(); } describe("NotificationDispatcher.notify", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); it("does not send when master switch is disabled", async () => { const send = vi.fn(async () => ({ ok: true })); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ enabled: false }), send, }); d.notify({ type: "turn-completed", title: "x", message: "y" }); await flush(); expect(send).not.toHaveBeenCalled(); }); it("does not send when per-event-type toggle is off", async () => { const send = vi.fn(async () => ({ ok: true })); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ events: { "turn-completed": false, "turn-error": true, "permission-required": true, "agent-spawned": false, }, }), send, }); d.notify({ type: "turn-completed", title: "x", message: "y" }); await flush(); expect(send).not.toHaveBeenCalled(); }); it("sends when enabled and toggle is on", async () => { const send = vi.fn(async () => ({ ok: true })); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.notify({ type: "turn-completed", title: "x", message: "y" }); await flush(); expect(send).toHaveBeenCalledTimes(1); }); it("does not throw or block when the transport rejects", async () => { const send = vi.fn(async () => { throw new Error("boom"); }); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow(); await flush(); expect(send).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalled(); }); it("dedupes events with the same dedupeKey within the window", async () => { const send = vi.fn(async () => ({ ok: true })); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send, dedupeWindowMs: 1000, }); const event: NotificationEvent = { type: "permission-required", title: "p", message: "p", dedupeKey: "permission:42", }; d.notify(event); d.notify(event); d.notify(event); await flush(); expect(send).toHaveBeenCalledTimes(1); }); it("does not dedupe events without a dedupeKey", async () => { const send = vi.fn(async () => ({ ok: true })); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.notify({ type: "turn-completed", title: "x", message: "y" }); d.notify({ type: "turn-completed", title: "x", message: "y" }); await flush(); expect(send).toHaveBeenCalledTimes(2); }); }); describe("NotificationDispatcher.attachToAgentManager", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); it("maps `done` → turn-completed (with tab title in the body)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send, getTabTitle: (id) => (id === "tab-1" ? "My chat" : null), }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).toHaveBeenCalledTimes(1); const event = send.mock.calls[0][1] as NotificationEvent; expect(event.type).toBe("turn-completed"); expect(event.title).toContain("My chat"); expect(event.tabId).toBe("tab-1"); }); it("maps `error` → turn-error and includes the error text", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.attachToAgentManager(source); source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 }); await flush(); expect(send).toHaveBeenCalledTimes(1); const event = send.mock.calls[0][1] as NotificationEvent; expect(event.type).toBe("turn-error"); expect(event.message).toContain("Rate limit"); expect(event.message).toContain("429"); }); it("ignores `status` events (would spam every transition)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.attachToAgentManager(source); source.emit({ type: "status", tabId: "tab-1", status: "running" }); source.emit({ type: "status", tabId: "tab-1", status: "idle" }); await flush(); expect(send).not.toHaveBeenCalled(); }); it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.attachToAgentManager(source); // Manual "new tab" with no agent slug ⇒ no notification. source.emit({ type: "tab-created", tabId: "tab-1", id: "tab-1", title: "New Tab", parentTabId: null, agentSlug: null, }); // Subagent (has a parent) ⇒ no notification. source.emit({ type: "tab-created", tabId: "tab-2", id: "tab-2", title: "Subagent", parentTabId: "tab-1", agentSlug: "researcher", }); // Top-level user agent ⇒ notify. source.emit({ type: "tab-created", tabId: "tab-3", id: "tab-3", title: "Refactor auth code", parentTabId: null, agentSlug: "engineer", }); await flush(); expect(send).toHaveBeenCalledTimes(1); const event = send.mock.calls[0][1] as NotificationEvent; expect(event.type).toBe("agent-spawned"); expect(event.message).toBe("Refactor auth code"); expect(event.title).toContain("engineer"); }); it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ events: { "turn-completed": false, "turn-error": true, "permission-required": true, "agent-spawned": false, }, }), send, }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).not.toHaveBeenCalled(); }); }); describe("NotificationDispatcher.attachToPermissionManager", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); it("notifies once per unique prompt id (dedupes re-emits)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makePermissionSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.attachToPermissionManager(source); source.emit({ id: "1", permission: "bash", description: "Run git status" }); source.emit({ id: "1", permission: "bash", description: "Run git status" }); source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" }); await flush(); expect(send).toHaveBeenCalledTimes(2); const events = send.mock.calls.map((c) => c[1] as NotificationEvent); expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]); expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true); }); }); describe("NotificationDispatcher.dispose", () => { it("releases attached subscriptions", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send }); d.attachToAgentManager(source); d.dispose(); source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).not.toHaveBeenCalled(); }); }); describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); const parents = new Map([ ["top-level", null], ["subagent", "top-level"], ]); const getTabParentId = (id: string): string | null | undefined => parents.get(id); it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send, getTabParentId, }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } }); source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).toHaveBeenCalledTimes(1); expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level"); }); it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send, getTabParentId, }); d.attachToAgentManager(source); source.emit({ type: "error", tabId: "subagent", error: "boom" }); source.emit({ type: "error", tabId: "top-level", error: "boom" }); await flush(); expect(send).toHaveBeenCalledTimes(1); expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level"); }); it("still notifies subagents when notifySubagents=true", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: true }), send, getTabParentId, }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } }); source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).toHaveBeenCalledTimes(2); }); it("does NOT gate permission-required (subagents must still get human input)", async () => { const send = vi.fn(async () => ({ ok: true })); const psource = makePermissionSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send, getTabParentId, }); d.attachToPermissionManager(psource); psource.emit({ id: "p1", permission: "bash", description: "git status" }); await flush(); expect(send).toHaveBeenCalledTimes(1); expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required"); }); it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send, // intentionally NO getTabParentId }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } }); await flush(); // Without a lookup, the dispatcher can't prove this is a subagent; it // must err on the side of notifying so legitimate top-level events // aren't silently dropped. expect(send).toHaveBeenCalledTimes(1); }); it("falls back to notifying when getTabParentId throws or returns undefined", async () => { const send = vi.fn(async () => ({ ok: true })); const source = makeAgentSource(); const d = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send, getTabParentId: () => { throw new Error("db unavailable"); }, }); d.attachToAgentManager(source); source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } }); await flush(); expect(send).toHaveBeenCalledTimes(1); const send2 = vi.fn(async () => ({ ok: true })); const source2 = makeAgentSource(); const d2 = new NotificationDispatcher({ loadConfig: () => makeConfig({ notifySubagents: false }), send: send2, getTabParentId: () => undefined, }); d2.attachToAgentManager(source2); source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } }); await flush(); expect(send2).toHaveBeenCalledTimes(1); }); });