From 25b5f5bad5ca3e5884cd700d67c868a1f02347b4 Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Mon, 12 Oct 2026 22:37:07 -0400 Subject: [PATCH] feat(notifications): typed completion notification events Adds a renderer-side typed event contract and broadcast for delivered completion notifications. Event ids are per-emission renderer identifiers, not durable identities; capture is gated by notification eligibility, so this is not a universal completion history. --- .../useCompletionNotifications.test.ts | 50 ++++++++ .../hooks/useCompletionNotifications.ts | 19 +++ .../__tests__/completionEvents.test.ts | 109 ++++++++++++++++++ src/shared/notifications/completionEvents.ts | 107 +++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 src/shared/notifications/__tests__/completionEvents.test.ts create mode 100644 src/shared/notifications/completionEvents.ts diff --git a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts index 4af37f1fe..fcd2f598a 100644 --- a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts +++ b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts @@ -699,4 +699,54 @@ describe("useCompletionNotifications", () => { ), ); }); + + it("emits a typed completion event with session/project/navigation refs on delivery", async () => { + const { subscribeToCompletionNotificationEvents } = await import( + "@/shared/notifications/completionEvents" + ); + const seen = vi.fn(); + const unsubscribe = subscribeToCompletionNotificationEvents(seen); + + let focusChanged: ((event: { payload: boolean }) => void) | null = null; + mocks.getCurrentWindow.mockReturnValue({ + onFocusChanged: vi.fn((handler) => { + focusChanged = handler; + return Promise.resolve(vi.fn()); + }), + unminimize: vi.fn().mockResolvedValue(undefined), + show: vi.fn().mockResolvedValue(undefined), + setFocus: vi.fn().mockResolvedValue(undefined), + }); + + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(focusChanged).toBeTruthy()); + + useChatSessionStore.getState().addSession({ + id: "session-5", + title: "Event source", + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + messageCount: 1, + projectId: "project-7", + }); + useChatStore.getState().setMessages("session-5", [makeMsg("completed")]); + + act(() => { + focusChanged?.({ payload: false }); + useChatStore.getState().setChatState("session-5", "streaming"); + useChatStore.getState().setChatState("session-5", "idle"); + }); + + await waitFor(() => expect(seen).toHaveBeenCalledTimes(1)); + expect(seen).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-5", + projectId: "project-7", + outcome: "completed", + body: "Event source finished", + navigation: { kind: "session", sessionId: "session-5" }, + }), + ); + unsubscribe(); + }); }); diff --git a/src/shared/hooks/useCompletionNotifications.ts b/src/shared/hooks/useCompletionNotifications.ts index 9f6dd4dbb..fcb18e4aa 100644 --- a/src/shared/hooks/useCompletionNotifications.ts +++ b/src/shared/hooks/useCompletionNotifications.ts @@ -9,6 +9,10 @@ import { getNotificationPrefs } from "@/features/settings/lib/notificationPrefs" import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; import { isDefaultChatTitle } from "@/features/chat/lib/sessionTitle"; import { showCompletionNotificationToast } from "@/shared/notifications/CompletionNotificationToast"; +import { + createCompletionNotificationEvent, + emitCompletionNotificationEvent, +} from "@/shared/notifications/completionEvents"; import { getNotificationSoundResource, playNotificationSound, @@ -243,6 +247,21 @@ export function useCompletionNotifications( ) { return; } + // The completion is notification-eligible (enabled, not + // suppressed, not an actively viewed focused session). + // Publish the typed event here so consumers (e.g. a future + // notification center) see it regardless of which channel + // pref (desktop/in-app) ultimately renders it — note a + // disabled channel pref below can still skip rendering. + emitCompletionNotificationEvent( + createCompletionNotificationEvent({ + sessionId, + projectId: session?.projectId ?? null, + outcome, + body, + occurredAtMs: Date.now(), + }), + ); if (!windowFocused) { if (!currentPrefs.desktop) return; import("@tauri-apps/api/core").then(({ invoke }) => { diff --git a/src/shared/notifications/__tests__/completionEvents.test.ts b/src/shared/notifications/__tests__/completionEvents.test.ts new file mode 100644 index 000000000..047a4c1a2 --- /dev/null +++ b/src/shared/notifications/__tests__/completionEvents.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCompletionNotificationEvent, + emitCompletionNotificationEvent, + subscribeToCompletionNotificationEvents, +} from "../completionEvents"; + +describe("createCompletionNotificationEvent", () => { + it("derives the per-emission id from the session and emission moment", () => { + const first = createCompletionNotificationEvent({ + sessionId: "session-1", + outcome: "completed", + body: "Agent finished", + occurredAtMs: 1_750_000_000_000, + }); + const duplicate = createCompletionNotificationEvent({ + sessionId: "session-1", + outcome: "completed", + body: "Agent finished", + occurredAtMs: 1_750_000_000_000, + }); + const other = createCompletionNotificationEvent({ + sessionId: "session-2", + outcome: "completed", + body: "Agent finished", + occurredAtMs: 1_750_000_000_000, + }); + + expect(first.id).toBe(duplicate.id); + expect(first.id).not.toBe(other.id); + }); + + it("carries session, project, outcome, and navigation references", () => { + const event = createCompletionNotificationEvent({ + sessionId: "session-1", + projectId: "project-9", + outcome: "error", + body: "Agent encountered an error", + occurredAtMs: 1_750_000_000_000, + }); + + expect(event).toMatchObject({ + sessionId: "session-1", + projectId: "project-9", + outcome: "error", + body: "Agent encountered an error", + navigation: { kind: "session", sessionId: "session-1" }, + }); + expect(event.occurredAt).toBe(new Date(1_750_000_000_000).toISOString()); + }); + + it("defaults projectId to null for unattached sessions", () => { + const event = createCompletionNotificationEvent({ + sessionId: "session-1", + outcome: "stopped", + body: "Agent was stopped", + occurredAtMs: 1, + }); + expect(event.projectId).toBeNull(); + }); +}); + +describe("completion notification event bus", () => { + it("delivers events to subscribers and honors unsubscribe", () => { + const seen = vi.fn(); + const unsubscribe = subscribeToCompletionNotificationEvents(seen); + const event = createCompletionNotificationEvent({ + sessionId: "session-1", + outcome: "completed", + body: "Agent finished", + occurredAtMs: 2, + }); + + emitCompletionNotificationEvent(event); + expect(seen).toHaveBeenCalledExactlyOnceWith(event); + + unsubscribe(); + emitCompletionNotificationEvent(event); + expect(seen).toHaveBeenCalledTimes(1); + }); + + it("isolates listener failures from other listeners", () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const unsubscribeThrowing = subscribeToCompletionNotificationEvents(() => { + throw new Error("listener exploded"); + }); + const healthy = vi.fn(); + const unsubscribeHealthy = subscribeToCompletionNotificationEvents(healthy); + + emitCompletionNotificationEvent( + createCompletionNotificationEvent({ + sessionId: "session-1", + outcome: "completed", + body: "Agent finished", + occurredAtMs: 3, + }), + ); + + expect(healthy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + + unsubscribeThrowing(); + unsubscribeHealthy(); + errorSpy.mockRestore(); + }); +}); diff --git a/src/shared/notifications/completionEvents.ts b/src/shared/notifications/completionEvents.ts new file mode 100644 index 000000000..227858725 --- /dev/null +++ b/src/shared/notifications/completionEvents.ts @@ -0,0 +1,107 @@ +/** + * Typed completion-notification events. + * + * This is the minimal renderer-side contract for "an agent turn finished and + * the user was notified about it". It gives future consumers (e.g. a + * notification center) a stable, typed source of truth instead of scraping + * toast text. + * + * Scope and deliberate non-goals, matching current architecture: + * - Not a universal completion history. Events are emitted only when a + * completion passes the notification eligibility gates (notifications + * enabled, not suppressed, not an actively viewed focused session). + * Completions that never become notification-eligible are not captured. + * - No persistence. Durable notification history belongs in Goose core + * behind a typed ACP method, not in renderer storage. + * - No delivery logic. Delivery policy stays in useCompletionNotifications; + * this module only describes and broadcasts what happened. + */ + +export type CompletionNotificationEventOutcome = + | "completed" + | "error" + | "stopped"; + +/** Where activating the notification should take the user. */ +export interface SessionNavigationTarget { + kind: "session"; + sessionId: string; +} + +export type NotificationNavigationTarget = SessionNavigationTarget; + +export interface CompletionNotificationEvent { + /** + * Renderer-emission identifier: unique per emitted event within this + * renderer process (`completion::`). It is NOT a + * durable identity — it is minted at emission time, so the same underlying + * completion re-observed later (or in another process) gets a different id. + * Consumers may use it to key UI lists, not to deduplicate across restarts. + */ + id: string; + sessionId: string; + /** Owning project when the session is attached to one. */ + projectId: string | null; + outcome: CompletionNotificationEventOutcome; + /** The user-facing notification body (same text toast/desktop delivery uses). */ + body: string; + /** ISO-8601 timestamp of the completion. */ + occurredAt: string; + navigation: NotificationNavigationTarget; +} + +export function createCompletionNotificationEvent({ + sessionId, + projectId, + outcome, + body, + occurredAtMs, +}: { + sessionId: string; + projectId?: string | null; + outcome: CompletionNotificationEventOutcome; + body: string; + occurredAtMs: number; +}): CompletionNotificationEvent { + return { + id: `completion:${sessionId}:${occurredAtMs}`, + sessionId, + projectId: projectId ?? null, + outcome, + body, + occurredAt: new Date(occurredAtMs).toISOString(), + navigation: { kind: "session", sessionId }, + }; +} + +type CompletionNotificationEventListener = ( + event: CompletionNotificationEvent, +) => void; + +const listeners = new Set(); + +/** Subscribe to completion notification events. Returns an unsubscribe fn. */ +export function subscribeToCompletionNotificationEvents( + listener: CompletionNotificationEventListener, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Broadcast a completion notification event to all subscribers. A throwing + * listener must not break delivery or other listeners. + */ +export function emitCompletionNotificationEvent( + event: CompletionNotificationEvent, +): void { + for (const listener of listeners) { + try { + listener(event); + } catch (error) { + console.error("completion notification listener failed", error); + } + } +}