diff --git a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts index 4af37f1fe..705356ff1 100644 --- a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts +++ b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts @@ -699,4 +699,142 @@ describe("useCompletionNotifications", () => { ), ); }); + + it("preserves completion timestamps when suppression resolves out of order", async () => { + const { subscribeToCompletionNotificationEvents } = await import( + "@/shared/notifications/completionEvents" + ); + const seen: { sessionId: string; occurredAt: string }[] = []; + const unsubscribe = subscribeToCompletionNotificationEvents((event) => { + seen.push({ sessionId: event.sessionId, occurredAt: event.occurredAt }); + }); + const suppressions = { + "session-first": deferred(), + "session-second": deferred(), + }; + mocks.invoke.mockImplementation( + (command: string, args: { sessionId: string }) => + command === "should_suppress_completion_notification" + ? suppressions[args.sessionId as keyof typeof suppressions].promise + : Promise.resolve(undefined), + ); + + 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), + }); + + try { + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(focusChanged).toBeTruthy()); + + for (const sessionId of Object.keys(suppressions)) { + useChatSessionStore.getState().addSession({ + id: sessionId, + title: sessionId, + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + messageCount: 1, + }); + useChatStore.getState().setMessages(sessionId, [makeMsg("completed")]); + } + + const firstCompletionMs = 1_750_000_000_001; + const secondCompletionMs = 1_750_000_000_002; + const nowSpy = vi + .spyOn(Date, "now") + .mockReturnValueOnce(firstCompletionMs) + .mockReturnValueOnce(secondCompletionMs); + try { + act(() => { + focusChanged?.({ payload: false }); + useChatStore.getState().setChatState("session-first", "streaming"); + useChatStore.getState().setChatState("session-first", "idle"); + useChatStore.getState().setChatState("session-second", "streaming"); + useChatStore.getState().setChatState("session-second", "idle"); + }); + } finally { + nowSpy.mockRestore(); + } + + await waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); + await act(async () => { + suppressions["session-second"].resolve(false); + }); + await waitFor(() => expect(seen).toHaveLength(1)); + await act(async () => { + suppressions["session-first"].resolve(false); + }); + await waitFor(() => expect(seen).toHaveLength(2)); + + expect(seen).toEqual([ + { + sessionId: "session-second", + occurredAt: new Date(secondCompletionMs).toISOString(), + }, + { + sessionId: "session-first", + occurredAt: new Date(firstCompletionMs).toISOString(), + }, + ]); + } finally { + unsubscribe(); + } + }); + + 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..f695192d5 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, @@ -210,6 +214,7 @@ export function useCompletionNotifications( prev === "thinking") ) { pendingSessions.delete(sessionId); + const occurredAtMs = Date.now(); const chatStoreState = useChatStore.getState(); const isViewingThisSession = isSessionActivelyViewed( @@ -243,6 +248,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, + }), + ); 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..2f3a547d9 --- /dev/null +++ b/src/shared/notifications/__tests__/completionEvents.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCompletionNotificationEvent, + emitCompletionNotificationEvent, + subscribeToCompletionNotificationEvents, +} from "../completionEvents"; + +describe("createCompletionNotificationEvent", () => { + it("assigns distinct IDs to same-session events with the same timestamp", () => { + 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).not.toBe(duplicate.id); + expect(first.id).not.toBe(other.id); + expect(first.occurredAt).toBe(duplicate.occurredAt); + expect(first.occurredAt).toBe(new Date(1_750_000_000_000).toISOString()); + }); + + 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..63397a023 --- /dev/null +++ b/src/shared/notifications/completionEvents.ts @@ -0,0 +1,111 @@ +/** + * 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 event within this renderer + * process. 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; +} + +// This renderer-local sequence disambiguates events emitted in the same ms. +let nextCompletionEventSequence = 0; + +export function createCompletionNotificationEvent({ + sessionId, + projectId, + outcome, + body, + occurredAtMs, +}: { + sessionId: string; + projectId?: string | null; + outcome: CompletionNotificationEventOutcome; + body: string; + occurredAtMs: number; +}): CompletionNotificationEvent { + nextCompletionEventSequence += 1; + return { + id: `completion:${sessionId}:${occurredAtMs}:${nextCompletionEventSequence}`, + 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); + } + } +}