From 21cdb1199599c4dc6e2a941e52713ba6511cd675 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:30 +0900 Subject: feat(api): wire notification dispatcher into app + /notifications routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionManager: add onPromptAdded(listener) callback. Fires exactly once per unique pending prompt id, even when broadcastPending is called repeatedly for unrelated mutations (e.g. another prompt resolving while this one is still pending). app.ts: instantiate NotificationDispatcher, attach to both AgentManager and PermissionManager. Tab-title lookup via core's getTab so the notifications carry human-readable context instead of raw UUIDs. routes/notifications.ts: - GET /notifications — current config (auth token redacted) plus the event-type catalog and defaults - PUT /notifications — partial update; auth token semantics are undefined=keep, ''=clear, otherwise replace - POST /notifications/test — sends a test notification with the current config (rejects if disabled or topic invalid) Tests: - new permission-manager.test.ts covers the onPromptAdded contract (one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener throws don't break siblings) - existing routes.test.ts gets stubs for the new core notification exports so the @dispatch/core mock stays complete --- packages/api/src/app.ts | 18 +++++++ packages/api/src/permission-manager.ts | 55 ++++++++++++++++++++++ packages/api/src/routes/notifications.ts | 81 ++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 packages/api/src/routes/notifications.ts (limited to 'packages/api/src') diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 19cc193..24cef24 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,3 +1,4 @@ +import { getTab, NotificationDispatcher } from "@dispatch/core"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AgentManager } from "./agent-manager.js"; @@ -5,12 +6,28 @@ import { PermissionManager } from "./permission-manager.js"; import { agentsRoutes } from "./routes/agents.js"; import { configRoutes } from "./routes/config.js"; import { modelsRoutes, startWakeScheduler } from "./routes/models.js"; +import { notificationsRoutes } from "./routes/notifications.js"; import { skillsRoutes } from "./routes/skills.js"; import { tabsRoutes } from "./routes/tabs.js"; export const permissionManager = new PermissionManager(); export const agentManager = new AgentManager(permissionManager); +// ntfy.sh push notifications. The dispatcher reads its config from the +// `settings` table on every send, so config changes apply immediately — +// no restart, no re-attach needed. +export const notificationDispatcher = new NotificationDispatcher({ + getTabTitle: (tabId) => { + try { + return getTab(tabId)?.title ?? null; + } catch { + return null; + } + }, +}); +notificationDispatcher.attachToAgentManager(agentManager); +notificationDispatcher.attachToPermissionManager(permissionManager); + export const app = new Hono(); app.use( @@ -112,6 +129,7 @@ app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); app.route("/tabs", tabsRoutes); app.route("/agents", agentsRoutes); +app.route("/notifications", notificationsRoutes); // Start the wake scheduler on boot (restores persisted schedule) startWakeScheduler(); diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts index d98dc52..3a24d03 100644 --- a/packages/api/src/permission-manager.ts +++ b/packages/api/src/permission-manager.ts @@ -5,9 +5,25 @@ import { type Ruleset, } from "@dispatch/core"; +/** + * Listener fired exactly once per newly-created pending prompt. Used by + * the notification dispatcher so that a permission request triggers a + * push notification on the user's phone (without re-firing every time + * the pending list mutates for an unrelated reason). + */ +export type PromptAddedListener = (prompt: { + id: string; + permission: string; + description: string; + metadata: Record; +}) => void; + export class PermissionManager { private service = new PermissionService(); private wsClients: Map void> = new Map(); + private promptAddedListeners: Set = new Set(); + /** Ids that have already been broadcast as "added" — guards against re-emits. */ + private announcedPromptIds: Set = new Set(); registerClient(id: string, send: (data: unknown) => void): void { this.wsClients.set(id, send); @@ -25,6 +41,33 @@ export class PermissionManager { for (const send of this.wsClients.values()) { send(message); } + + // Detect newly-added prompts (ids present now that weren't before) and + // fire `promptAddedListeners` once for each. Resolved/rejected ids are + // pruned from `announcedPromptIds` so a future prompt that reuses an + // id (theoretical, given the monotonic counter) would still notify. + const currentIds = new Set(pending.map((p) => p.id)); + for (const id of this.announcedPromptIds) { + if (!currentIds.has(id)) this.announcedPromptIds.delete(id); + } + for (const p of pending) { + if (this.announcedPromptIds.has(p.id)) continue; + this.announcedPromptIds.add(p.id); + for (const listener of this.promptAddedListeners) { + try { + listener({ + id: p.id, + permission: p.request.permission, + description: p.request.description, + metadata: p.request.metadata, + }); + } catch (err) { + console.warn( + `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } } async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise { @@ -45,4 +88,16 @@ export class PermissionManager { getService(): PermissionService { return this.service; } + + /** + * Subscribe to "a new prompt is now pending" events. Fires once per + * unique prompt id, even if `broadcastPending` is called repeatedly + * for unrelated mutations. Returns an unsubscribe function. + */ + onPromptAdded(listener: PromptAddedListener): () => void { + this.promptAddedListeners.add(listener); + return () => { + this.promptAddedListeners.delete(listener); + }; + } } diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts new file mode 100644 index 0000000..57519bc --- /dev/null +++ b/packages/api/src/routes/notifications.ts @@ -0,0 +1,81 @@ +// `/notifications` — ntfy.sh config + test-send route. + +import { + defaultNtfyConfig, + loadNtfyConfig, + type NotificationEventType, + NTFY_EVENT_TYPES, + type NtfyConfig, + normalizeNtfyConfig, + redactNtfyConfig, + saveNtfyConfig, + sendNtfy, + validateTopicUrl, +} from "@dispatch/core"; +import { Hono } from "hono"; + +export const notificationsRoutes = new Hono(); + +notificationsRoutes.get("/", (c) => { + const config = loadNtfyConfig(); + return c.json({ + config: redactNtfyConfig(config), + eventTypes: NTFY_EVENT_TYPES, + defaults: defaultNtfyConfig(), + }); +}); + +notificationsRoutes.put("/", async (c) => { + const body = await c.req.json & { authToken?: string }>(); + const existing = loadNtfyConfig(); + + // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep + // the existing token (the GET response redacts it, so the frontend doesn't + // have it to send back). Any other string ⇒ replace. + let nextAuthToken = existing.authToken; + if (typeof body.authToken === "string") nextAuthToken = body.authToken; + + const merged = normalizeNtfyConfig({ + enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, + topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl, + authToken: nextAuthToken, + events: { ...existing.events, ...(body.events ?? {}) }, + }); + + if (merged.enabled) { + const err = validateTopicUrl(merged.topicUrl); + if (err) return c.json({ error: err }, 400); + } + + saveNtfyConfig(merged); + return c.json({ config: redactNtfyConfig(merged) }); +}); + +notificationsRoutes.post("/test", async (c) => { + const config = loadNtfyConfig(); + if (!config.enabled) { + return c.json({ ok: false, error: "Notifications are disabled" }, 400); + } + const err = validateTopicUrl(config.topicUrl); + if (err) return c.json({ ok: false, error: err }, 400); + + // Use a real event type so the per-event toggle is honored when wiring + // is tested end-to-end; pick `turn-completed` since it's the most + // common enabled-by-default event. + const eventType: NotificationEventType = "turn-completed"; + if (!config.events[eventType]) { + return c.json( + { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, + 400, + ); + } + + const result = await sendNtfy(config, { + type: eventType, + title: "Dispatch test notification", + message: "If you can see this, ntfy.sh notifications are wired up correctly.", + tags: ["bell"], + }); + if (!result.ok) return c.json(result, 502); + return c.json(result); +}); -- cgit v1.2.3 From 9c93086c0d4acaa1ed753488b12f72c2ca86a22c Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 10:12:11 +0900 Subject: feat(notifications): add notifySubagents toggle to suppress subagent turn pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parent agent that spawns 8 subagents was producing 9 "Turn complete" notifications per round — almost always noise. New `notifySubagents` config flag (defaults to false) gates `turn-completed` and `turn-error` from any tab with a `parentTabId`. The flag is intentionally NOT applied to `permission-required` — a subagent's permission prompt still needs a human tap to proceed, so suppressing it would silently hang the subagent. `agent-spawned` is already top-level-only by construction. Wiring: - core/notifications/types.ts: NtfyConfig.notifySubagents: boolean - core/notifications/config.ts: defaults to false; normalize() tolerates missing / wrong-typed values and falls back to false - core/notifications/dispatcher.ts: new optional TabParentLookup option (getTabParentId). When notifySubagents=false AND the lookup returns a non-empty parent id string, turn-completed/turn-error are dropped. Lookup failures (no lookup configured, throws, returns undefined) fall back to "treat as top-level" so legitimate top-level events are never silently dropped when the DB is briefly unreadable. - api/app.ts: wires getTabParentId via core's getTab(id)?.parentTabId - frontend SettingsPanel.svelte: "Include subagent tabs" checkbox with an explanatory hint that permission prompts still fire Tests (+9): - 3 in config.test.ts: default-false, explicit-true, wrong-typed fallback - 6 in dispatcher.test.ts: suppression of turn-completed/turn-error from subagents, no suppression when flag is true, permission-required not gated, graceful fallback when lookup is missing/throws/returns undefined Live ntfy.sh round-trip re-verified (status: 200). --- packages/api/src/app.ts | 11 ++ packages/api/tests/routes.test.ts | 2 + packages/core/src/notifications/config.ts | 3 + packages/core/src/notifications/dispatcher.ts | 49 ++++++++ packages/core/src/notifications/index.ts | 1 + packages/core/src/notifications/types.ts | 8 ++ packages/core/tests/notifications/config.test.ts | 30 +++++ .../core/tests/notifications/dispatcher.test.ts | 138 +++++++++++++++++++++ .../src/lib/components/SettingsPanel.svelte | 17 +++ 9 files changed, 259 insertions(+) (limited to 'packages/api/src') diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 24cef24..0dabb0d 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -24,6 +24,17 @@ export const notificationDispatcher = new NotificationDispatcher({ return null; } }, + getTabParentId: (tabId) => { + try { + // `undefined` when the lookup fails (tab not found / DB unavailable) + // so the dispatcher falls back to "treat as top-level" rather than + // silently dropping notifications. + const row = getTab(tabId); + return row ? row.parentTabId : undefined; + } catch { + return undefined; + } + }, }); notificationDispatcher.attachToAgentManager(agentManager); notificationDispatcher.attachToPermissionManager(permissionManager); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index c07f932..1fad690 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -290,6 +290,7 @@ vi.mock("@dispatch/core", () => ({ "permission-required": true, "agent-spawned": false, }, + notifySubagents: false, }; }, saveNtfyConfig() {}, @@ -307,6 +308,7 @@ vi.mock("@dispatch/core", () => ({ "permission-required": true, "agent-spawned": false, }, + notifySubagents: false, }; }, redactNtfyConfig(c: { authToken?: string }) { diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts index 310c606..faa316e 100644 --- a/packages/core/src/notifications/config.ts +++ b/packages/core/src/notifications/config.ts @@ -17,6 +17,7 @@ export function defaultNtfyConfig(): NtfyConfig { topicUrl: "", authToken: "", events: { ...NTFY_DEFAULT_EVENTS }, + notifySubagents: false, }; } @@ -34,6 +35,8 @@ export function normalizeNtfyConfig(raw: unknown): NtfyConfig { topicUrl: typeof obj.topicUrl === "string" ? obj.topicUrl : base.topicUrl, authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, events: { ...base.events }, + notifySubagents: + typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents, }; const rawEvents = obj.events; if (rawEvents && typeof rawEvents === "object") { diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts index 4f4fc79..01ce00c 100644 --- a/packages/core/src/notifications/dispatcher.ts +++ b/packages/core/src/notifications/dispatcher.ts @@ -31,6 +31,15 @@ export interface PermissionPromptSource { /** Look up a human-readable tab title for nicer notification text. */ export type TabTitleLookup = (tabId: string) => string | null; +/** + * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no + * parent) and `undefined` when the lookup can't be performed (no DB, tab + * not found). Both non-strings cause the dispatcher to fall back to + * "treat as top-level" to avoid silently dropping notifications when the + * lookup is broken. + */ +export type TabParentLookup = (tabId: string) => string | null | undefined; + export interface DispatcherOptions { /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */ loadConfig?: () => NtfyConfig; @@ -40,6 +49,13 @@ export interface DispatcherOptions { fetchImpl?: FetchLike; /** Look up a tab title for richer titles. */ getTabTitle?: TabTitleLookup; + /** + * Look up a tab's `parentTabId`. Used to honour the + * `notifySubagents` config flag — when false, `turn-completed` / + * `turn-error` from subagent tabs (those with a parent) are + * suppressed. + */ + getTabParentId?: TabParentLookup; /** * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit * the whole pending list on every change, so dedupe is essential. @@ -51,6 +67,7 @@ export class NotificationDispatcher { private loadConfig: () => NtfyConfig; private send: (config: NtfyConfig, event: NotificationEvent) => Promise; private getTabTitle: TabTitleLookup | undefined; + private getTabParentId: TabParentLookup | undefined; private dedupeWindowMs: number; /** Recently-sent dedupeKey → expiresAt epoch ms. */ private recentlySent = new Map(); @@ -61,6 +78,7 @@ export class NotificationDispatcher { this.send = opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined)); this.getTabTitle = opts.getTabTitle; + this.getTabParentId = opts.getTabParentId; this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000; } @@ -101,12 +119,22 @@ export class NotificationDispatcher { * * `status` events are ignored — they fire on every transition and we'd * either spam or duplicate the `done`/`error` notifications. + * + * Turn events from subagent tabs are suppressed when + * `config.notifySubagents === false` (the default). A parent agent + * spawning 8 subagents would otherwise produce 9 "Turn complete" + * pushes per round; almost always noise. Permission prompts are NOT + * gated this way — a subagent's permission request still needs human + * input to proceed, so suppressing those would silently hang the + * subagent. */ attachToAgentManager(source: AgentEventSource): () => void { const unsub = source.onEvent((event) => { if (event.type === "done") { + if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnCompleted(event)); } else if (event.type === "error") { + if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnError(event)); } else if (event.type === "tab-created") { const ev = event as unknown as { @@ -213,6 +241,27 @@ export class NotificationDispatcher { return `tab ${tabId.slice(0, 8)}`; } + /** + * Returns true when this `tabId` belongs to a subagent AND the user has + * opted out of subagent turn notifications. On lookup failure + * (`getTabParentId` returns `undefined` or throws) we err on the side + * of "not a subagent" — better to over-notify than to silently drop + * legitimate top-level events when the DB is briefly unreadable. + */ + private isSubagentSuppressed(tabId: string): boolean { + const config = this.loadConfig(); + if (config.notifySubagents) return false; + if (!this.getTabParentId) return false; + let parent: string | null | undefined; + try { + parent = this.getTabParentId(tabId); + } catch { + return false; + } + // Only a non-empty string parent id means "this tab is a subagent". + return typeof parent === "string" && parent.length > 0; + } + // ─── Dedupe helpers ─────────────────────────────────────────── private isDuplicate(key: string): boolean { diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts index ea99a58..d9d934d 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -14,6 +14,7 @@ export { type DispatcherOptions, NotificationDispatcher, type PermissionPromptSource, + type TabParentLookup, type TabTitleLookup, } from "./dispatcher.js"; export { type FetchLike, type NtfySendResult, sendNtfy, validateTopicUrl } from "./ntfy.js"; diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index f6baa27..238cb68 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -57,12 +57,20 @@ export interface NotificationEvent { * - `authToken` — optional bearer token for private ntfy servers. * - `events` — per-event-type enable map. Missing entries default to OFF * so a newly-added event type doesn't silently start firing. + * - `notifySubagents` — when false (default), `turn-completed` and + * `turn-error` notifications from subagent tabs (tabs with a + * `parentTabId`) are suppressed. A parent agent that spawns 8 + * subagents would otherwise push 9 "Turn complete" notifications per + * round — usually noise. `permission-required` is NOT gated: even a + * subagent's permission prompt needs a human tap to proceed. + * `agent-spawned` is already top-level-only by construction. */ export interface NtfyConfig { enabled: boolean; topicUrl: string; authToken: string; events: Record; + notifySubagents: boolean; } /** All event types this build knows about (the source of truth for UI). */ diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts index 64a9637..c110788 100644 --- a/packages/core/tests/notifications/config.test.ts +++ b/packages/core/tests/notifications/config.test.ts @@ -34,6 +34,7 @@ describe("defaultNtfyConfig", () => { expect(cfg.events["turn-error"]).toBe(true); expect(cfg.events["permission-required"]).toBe(true); expect(cfg.events["agent-spawned"]).toBe(false); + expect(cfg.notifySubagents).toBe(false); }); }); @@ -72,6 +73,34 @@ describe("normalizeNtfyConfig", () => { }); }); +describe("normalizeNtfyConfig — notifySubagents", () => { + it("defaults notifySubagents to false when absent", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topicUrl: "https://ntfy.sh/x", + }); + expect(normalized.notifySubagents).toBe(false); + }); + + it("respects an explicit notifySubagents=true", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topicUrl: "https://ntfy.sh/x", + notifySubagents: true, + }); + expect(normalized.notifySubagents).toBe(true); + }); + + it("falls back to default when notifySubagents is wrong-typed", () => { + const normalized = normalizeNtfyConfig({ + enabled: true, + topicUrl: "https://ntfy.sh/x", + notifySubagents: "yes" as unknown, + }); + expect(normalized.notifySubagents).toBe(false); + }); +}); + describe("load/save round-trip", () => { beforeEach(() => { fakeSettings.clear(); @@ -92,6 +121,7 @@ describe("load/save round-trip", () => { "permission-required": true, "agent-spawned": true, }, + notifySubagents: true, } as const; saveNtfyConfig({ ...cfg }); const loaded = loadNtfyConfig(); diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts index db05de4..750552c 100644 --- a/packages/core/tests/notifications/dispatcher.test.ts +++ b/packages/core/tests/notifications/dispatcher.test.ts @@ -25,6 +25,10 @@ function makeConfig(overrides: Partial = {}): NtfyConfig { "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, }; } @@ -321,3 +325,137 @@ describe("NotificationDispatcher.dispose", () => { 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); + }); +}); diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 8031500..08e86ac 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -36,6 +36,7 @@ interface NtfyConfigView { authToken: string; hasAuthToken?: boolean; events: Record; + notifySubagents: boolean; } const NTFY_EVENT_LABELS: Record = { @@ -56,6 +57,7 @@ const DEFAULT_NTFY: NtfyConfigView = { "permission-required": true, "agent-spawned": false, }, + notifySubagents: false, }; let ntfy = $state({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } }); @@ -162,6 +164,7 @@ async function saveNtfy(): Promise { enabled: ntfy.enabled, topicUrl: ntfy.topicUrl, events: ntfy.events, + notifySubagents: ntfy.notifySubagents, }; if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput; const res = await fetch(`${apiBase}/notifications`, { @@ -469,6 +472,20 @@ $effect(() => { {/each} +
+ + + Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang. + +
+