diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index ae2ed3d10de3c..93a3d55944554 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -254,6 +254,53 @@ it("presents completion notifications and sound as soon as the shell completes", expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); }); +it("stays quiet for runs a pending Monitor wakes and notifies when the agent finishes", async () => { + await using app = setup(); + state.mode = "notifications-and-sound"; + app.start(); + const monitor = [{ taskId: "monitor-1", kind: "monitor" }]; + const completedRun = ( + ordinal: number, + pendingBackgroundTasks: ReadonlyArray<{ taskId: string; kind: string }>, + ) => { + const requestedAt = `2026-09-13T07:0${ordinal}:00.000Z`; + const raw = toV2ThreadShell({ + ...thread, + latestTurn: { + turnId: `turn-${ordinal}`, + state: "completed", + completedAt: `2026-09-13T07:0${ordinal}:03.000Z`, + }, + }); + return { + status: "live", + snapshot: Option.some({ + threads: [ + { + ...raw, + latestRunRequestedAt: DateTime.makeUnsafe(requestedAt), + latestUserAuthoredMessageAt: SHELL_NOW, + pendingBackgroundTasks, + }, + ], + }), + }; + }; + // Run 0 answers the user, runs 1-3 are Monitor wakes, run 4 is the wake after it ended. + state.shells.set("one", completedRun(0, monitor)); + for (const ordinal of [1, 2, 3]) { + state.shells.set("one", shell()); + state.shells.set("one", completedRun(ordinal, monitor)); + } + state.shells.set("one", shell()); + state.shells.set("one", completedRun(4, [])); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Thread completed", + "Thread completed", + ]); + expect(state.sound).toHaveBeenCalledTimes(2); +}); + it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "notifies for offline completion and %s once across reconnects", async (flag) => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 1fe126ad37da3..a49f4ca0a5048 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,3 +1,6 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { @@ -45,3 +48,23 @@ export function createThreadNotificationGate( }, }; } + +// A Monitor wakes the agent on every event it prints. The run for each wake ends as completed +// once the agent acknowledges the event, although the task it is monitoring has not finished. +export function isMonitorWakeRun( + thread: Pick< + OrchestrationV2ThreadShell, + "latestRunRequestedAt" | "latestUserAuthoredMessageAt" | "pendingBackgroundTasks" + >, +): boolean { + const { latestRunRequestedAt, latestUserAuthoredMessageAt } = thread; + // Servers without latestUserAuthoredMessageAt cannot tell a wake from the user's own run. + if (latestRunRequestedAt == null || latestUserAuthoredMessageAt === undefined) return false; + const startedByUser = + latestUserAuthoredMessageAt !== null && + !DateTime.isGreaterThan(latestRunRequestedAt, latestUserAuthoredMessageAt); + const monitorPending = (thread.pendingBackgroundTasks ?? []).some( + (task) => task.kind === "monitor", + ); + return !startedByUser && monitorPending; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a7134b481d131..bd14a8cfeced3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -33,6 +33,7 @@ import { import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, + isMonitorWakeRun, type ThreadNotificationKind, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -280,7 +281,7 @@ export function startThreadNotifications( for (const [prior, state] of changed) { entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, - quiet: state.raw.latestRunStopRequested === true, + quiet: state.raw.latestRunStopRequested === true || isMonitorWakeRun(state.raw), }); } };