From 2d3b8e4ff0da188c780b27873bafab7e8c6c40c9 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:03:00 +0200 Subject: [PATCH] fix(gsd): dedupe repeated notifications --- .../extensions/gsd/notification-store.ts | 20 ++++++++++++++- .../extensions/gsd/notification-widget.ts | 2 +- .../gsd/tests/notification-store.test.ts | 17 +++++++++++++ .../gsd/tests/notification-widget.test.ts | 25 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/notification-widget.test.ts diff --git a/src/resources/extensions/gsd/notification-store.ts b/src/resources/extensions/gsd/notification-store.ts index d79d4a33c..50484597f 100644 --- a/src/resources/extensions/gsd/notification-store.ts +++ b/src/resources/extensions/gsd/notification-store.ts @@ -26,12 +26,15 @@ export interface NotificationEntry { const MAX_ENTRIES = 500; const FILENAME = "notifications.jsonl"; const LOCKFILE = "notifications.lock"; +const DEDUP_WINDOW_MS = 30_000; +const DEDUP_PRUNE_THRESHOLD = 200; // ─── Module State ─────────────────────────────────────────────────────── let _basePath: string | null = null; let _lineCount = 0; // Hint for rotation — not authoritative for public API let _suppressCount = 0; +let _recentMessageTimestamps = new Map(); // ─── Public API ───────────────────────────────────────────────────────── @@ -40,6 +43,9 @@ let _suppressCount = 0; * project root. Seeds in-memory counters from the existing file on disk. */ export function initNotificationStore(basePath: string): void { + if (_basePath !== basePath) { + _recentMessageTimestamps.clear(); + } _basePath = basePath; // Seed line count hint for rotation — public counters read from disk _lineCount = _readEntriesFromDisk(basePath).length; @@ -56,12 +62,23 @@ export function appendNotification( ): void { if (!_basePath) return; if (_suppressCount > 0) return; + const persistedMessage = message.length > 500 ? message.slice(0, 500) + "…" : message; + const dedupKey = `${_basePath}:${severity}:${source}:${persistedMessage}`; + const now = Date.now(); + const lastSeen = _recentMessageTimestamps.get(dedupKey); + if (lastSeen !== undefined && now - lastSeen < DEDUP_WINDOW_MS) return; + _recentMessageTimestamps.set(dedupKey, now); + if (_recentMessageTimestamps.size > DEDUP_PRUNE_THRESHOLD) { + for (const [key, ts] of _recentMessageTimestamps) { + if (now - ts > DEDUP_WINDOW_MS) _recentMessageTimestamps.delete(key); + } + } const entry: NotificationEntry = { id: randomUUID(), ts: new Date().toISOString(), severity, - message: message.length > 500 ? message.slice(0, 500) + "…" : message, + message: persistedMessage, source, read: false, }; @@ -181,6 +198,7 @@ export function _resetNotificationStore(): void { _basePath = null; _lineCount = 0; _suppressCount = 0; + _recentMessageTimestamps = new Map(); } // ─── Internal ─────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/notification-widget.ts b/src/resources/extensions/gsd/notification-widget.ts index 8a963be5e..648e2af65 100644 --- a/src/resources/extensions/gsd/notification-widget.ts +++ b/src/resources/extensions/gsd/notification-widget.ts @@ -25,7 +25,7 @@ export function buildNotificationWidgetLines(): string[] { ? latest.message.slice(0, msgMax - 1) + "…" : latest.message; - return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} to view)`]; + return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} or /gsd notifications)`]; } // ─── Widget init ──────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/notification-store.test.ts b/src/resources/extensions/gsd/tests/notification-store.test.ts index 8f13fb873..f17f9dd0e 100644 --- a/src/resources/extensions/gsd/tests/notification-store.test.ts +++ b/src/resources/extensions/gsd/tests/notification-store.test.ts @@ -187,6 +187,23 @@ describe("notification-store", () => { assert.ok(!entries.some((e) => e.message === "suppressed")); }); + test("appendNotification suppresses identical messages within the dedup window", (t) => { + initNotificationStore(tmp); + let now = 1_000; + t.mock.method(Date, "now", () => now); + + appendNotification("same", "warning"); + now += 1_000; + appendNotification("same", "warning"); + now += 31_000; + appendNotification("same", "warning"); + + const entries = readNotifications(); + assert.equal(entries.length, 2); + assert.equal(entries[0].message, "same"); + assert.equal(entries[1].message, "same"); + }); + test("suppressPersistence is ref-counted", () => { initNotificationStore(tmp); suppressPersistence(); diff --git a/src/resources/extensions/gsd/tests/notification-widget.test.ts b/src/resources/extensions/gsd/tests/notification-widget.test.ts new file mode 100644 index 000000000..f6cd2eee7 --- /dev/null +++ b/src/resources/extensions/gsd/tests/notification-widget.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { initNotificationStore, appendNotification, _resetNotificationStore } from "../notification-store.js"; +import { buildNotificationWidgetLines } from "../notification-widget.js"; + +test("buildNotificationWidgetLines includes slash-command fallback for unread notifications", () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-notification-widget-")); + try { + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + _resetNotificationStore(); + initNotificationStore(tmp); + appendNotification("Need attention", "warning"); + + const lines = buildNotificationWidgetLines(); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /\/gsd notifications/); + } finally { + _resetNotificationStore(); + rmSync(tmp, { recursive: true, force: true }); + } +});