summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 10:12:11 +0900
committerAdam Malczewski <[email protected]>2026-06-01 10:12:11 +0900
commit9c93086c0d4acaa1ed753488b12f72c2ca86a22c (patch)
treee74597029db76d50ea6e48c2bb9bcc62a84fd079 /packages/core/tests
parent41857893e0a3af7afb7b716a2274dc4aec29e61c (diff)
downloaddispatch-9c93086c0d4acaa1ed753488b12f72c2ca86a22c.tar.gz
dispatch-9c93086c0d4acaa1ed753488b12f72c2ca86a22c.zip
feat(notifications): add notifySubagents toggle to suppress subagent turn pings
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).
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/notifications/config.test.ts30
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts138
2 files changed, 168 insertions, 0 deletions
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> = {}): 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<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ const parents = new Map<string, string | null>([
+ ["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);
+ });
+});