Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/shared/hooks/__tests__/useCompletionNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
19 changes: 19 additions & 0 deletions src/shared/hooks/useCompletionNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 · Capture completion time before suppression (blocking)

The event contract documents occurredAt as the completion timestamp, but Date.now() is evaluated only after shouldSuppressCompletionNotification resolves. That asynchronous Tauri check can delay emission, so the field records policy-check completion rather than the agent completion; if several checks resolve out of order, event chronology can also disagree with completion chronology.

User effect: A notification history built on this contract can show completions at the wrong time or in the wrong order, especially when suppression checks are slow.

Recommended fix: Capture the timestamp when the session first transitions from active to idle, before starting the asynchronous suppression check, and pass that stable value into the event factory after eligibility is confirmed.

Test: Use a deferred suppression response, complete two sessions in a known order, resolve the checks in reverse order, and assert each emitted occurredAt preserves its original completion time and ordering.

}),
);
if (!windowFocused) {
if (!currentPrefs.desktop) return;
import("@tauri-apps/api/core").then(({ invoke }) => {
Expand Down
109 changes: 109 additions & 0 deletions src/shared/notifications/__tests__/completionEvents.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
107 changes: 107 additions & 0 deletions src/shared/notifications/completionEvents.ts
Original file line number Diff line number Diff line change
@@ -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:<sessionId>:<emission ms>`). 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}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 · Guarantee per-emission event IDs (blocking)

The contract promises an identifier unique per emitted event, but the identifier contains only sessionId and a millisecond timestamp. Two completions for the same session can be processed within one millisecond and receive the same ID; the new factory test explicitly demonstrates that identical inputs produce an identical ID.

User effect: A notification list keyed or deduplicated by this contract can collapse one of two rapid completions, hiding a real event from the user.

Recommended fix: Include a renderer-local monotonic sequence or another collision-resistant per-emission value in the identifier while keeping its documented non-durable scope.

Test: Create two events for the same session and same occurredAtMs and assert that their IDs differ while both retain the same occurredAt value.

sessionId,
projectId: projectId ?? null,
outcome,
body,
occurredAt: new Date(occurredAtMs).toISOString(),
navigation: { kind: "session", sessionId },
};
}

type CompletionNotificationEventListener = (
event: CompletionNotificationEvent,
) => void;

const listeners = new Set<CompletionNotificationEventListener>();

/** 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);
}
}
}
Loading