diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 32007af08573f..e0b0b3035d5eb 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1081,6 +1081,9 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "creationSource" | "latestRunCompletedAt" | "archivedAt" + | "snoozedUntil" + | "snoozedAt" + | "pendingGuestMessageCount" | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" @@ -8685,6 +8688,9 @@ export const layer: Layer.Layer = projectId: state.thread.projectId, creationSource: state.thread.creationSource, archivedAt: state.thread.archivedAt, + snoozedUntil: state.thread.snoozedUntil ?? null, + snoozedAt: state.thread.snoozedAt ?? null, + pendingGuestMessageCount: pendingGuestMessageCount(state.thread.guestShare), activeRunId: state.activeRunId, latestRunId: state.latestRunId, hasActionableProposedPlan: state.hasActionableProposedPlan, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 68e2289524c09..10388e99fe7c5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -753,6 +753,37 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect( + "clears a completion that landed while snoozed, even when processed after the wake", + () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const snooze = { + snoozedAt: DateTime.makeUnsafe(NOW), + snoozedUntil: DateTime.makeUnsafe("2026-09-04T13:00:00.000Z"), + }; + yield* Ref.set(currentShell, shell(snooze)); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("2 hours"); + yield* Ref.set( + currentShell, + shell({ + ...snooze, + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:30:00.000Z"), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", null], + ); + }), + ); + it.effect("still publishes a completion when the thread settles during the hold", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 76f854548deb6..ef2e34799742c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -51,6 +51,7 @@ import { import { EnvironmentSigningKey } from "../cloud/environmentKeys.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { forkParked } from "../serverActivation.ts"; @@ -360,12 +361,27 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; return { projectId: input.thread.value.projectId, - state: sanitizeRelayAgentActivityState(state), + state: snoozedCompletion(input.thread.value, state) + ? null + : sanitizeRelayAgentActivityState(state), subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; } +// Checked at completion time, so a completion processed after the wake stays silent too. +function snoozedCompletion( + thread: AgentAwarenessThread, + state: { readonly phase: string } | null, +): boolean { + if (state?.phase !== "completed" || thread.latestRunCompletedAt == null) return false; + const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + if (thread.snoozedAt != null && completedAt < DateTime.toEpochMillis(thread.snoozedAt)) { + return false; + } + return isSnoozed(thread, completedAt); +} + function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number): boolean { return ( thread.latestRunCompletedAt != null && diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 965dba45ce8a2..0efca647316ba 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { createThreadNotificationGate, + isSnoozedForNotification, RELEASED_COMPLETION_GRACE_MS, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -25,7 +26,8 @@ const woke = (at: number): ThreadNotificationObservation => ({ ...completed(at), backgroundWake: true, }); -const visible = { archived: false, quiet: false }; +const visible = { archived: false, quiet: false, snoozed: false }; +const snoozed = { ...visible, snoozed: true }; function fakeTimers(): Disposable { vi.useFakeTimers(); @@ -44,6 +46,16 @@ function harness() { } describe("createThreadNotificationGate", () => { + it("skips a completion seen while snoozed, but still notifies attention", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1), snoozed); + observe({ ...completed(1) }); + expect(notify).not.toHaveBeenCalled(); + observe({ ...working, attention: "run-2:approval" }, snoozed); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + it("notifies a completion immediately and only once", () => { const { notify, observe } = harness(); observe(working); @@ -180,6 +192,16 @@ describe("createThreadNotificationGate", () => { expect(notify).toHaveBeenCalledTimes(1); }); + it("drops a pending wake completion when the thread is snoozed", () => { + using _timers = fakeTimers(); + const { notify, observe } = harness(); + observe(waking); + observe(woke(1)); + observe({ ...woke(1) }, snoozed); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify).not.toHaveBeenCalled(); + }); + it("drops a pending wake completion when the thread is removed or the gate is cleared", () => { using _timers = fakeTimers(); for (const reset of [ @@ -195,3 +217,30 @@ describe("createThreadNotificationGate", () => { } }); }); + +describe("isSnoozedForNotification", () => { + const snoozedAt = Date.parse("2026-01-01T11:00:00.000Z"); + const wakeAt = Date.parse("2026-01-01T12:00:00.000Z"); + const thread = { + snoozedAt: new Date(snoozedAt).toISOString(), + snoozedUntil: new Date(wakeAt).toISOString(), + hasPendingApprovals: false, + hasPendingUserInput: false, + }; + const later = wakeAt + 60_000; + + it.each([ + ["during the snooze, processed after the wake", thread, wakeAt - 1, later, true], + ["before the snooze, processed after the wake", thread, snoozedAt - 1, later, false], + ["after the wake", thread, wakeAt + 1, later, false], + [ + "after an early wake for attention", + { ...thread, hasPendingApprovals: true }, + wakeAt - 1, + wakeAt - 1, + false, + ], + ])("a completion %s is suppressed: %s", (_name, shell, completedAt, now, expected) => { + expect(isSnoozedForNotification(shell, completedAt, now)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 81002179ea9c7..057d976a17837 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,3 +1,8 @@ +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; + export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { @@ -16,6 +21,21 @@ export interface ThreadNotificationObservation { /** Matches the push relay's completion confirmation: a final wake can start right after a progress wake. */ export const RELEASED_COMPLETION_GRACE_MS = 5_000; +/** + * Whether completion notifications are suppressed by snooze. A completion that landed + * during the snooze stays suppressed even when its update is processed after the wake. + */ +export function isSnoozedForNotification( + thread: ThreadSnoozeShell, + completedAt: number | null, + now: number, +): boolean { + if (effectiveSnoozed(thread, { now: new Date(now).toISOString() })) return true; + if (completedAt === null) return false; + if (thread.snoozedAt != null && completedAt < Date.parse(thread.snoozedAt)) return false; + return effectiveSnoozed(thread, { now: new Date(completedAt).toISOString() }); +} + export function createThreadNotificationGate( notify: (key: Key, kind: ThreadNotificationKind) => void, ) { @@ -64,17 +84,18 @@ export function createThreadNotificationGate( key: Key, prior: ThreadNotificationObservation | undefined, next: ThreadNotificationObservation, - options: { readonly archived: boolean; readonly quiet: boolean }, + options: { readonly archived: boolean; readonly quiet: boolean; readonly snoozed: boolean }, ): void { // The grace survives updates that keep the wake's completion; anything else cancels it. const pending = releases.get(key); - if (pending?.at === next.completedAt && !options.archived && !options.quiet) { + const silenced = options.archived || options.quiet || options.snoozed; + if (pending?.at === next.completedAt && !silenced) { if (pending.timer === undefined) release(key, pending.at); return; } cancel(key); - // The first sighting, archived threads, and stopped or silent runs never notify completion. - if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + // The first sighting, archived or snoozed threads, and stopped or silent runs never notify completion. + if (!prior || silenced) skip(key, next.completedAt); const fresh = attend(key, prior, next); if (!prior || options.archived) return; if (fresh) { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 73daf000cadd0..0268da23dd2e1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -35,6 +35,7 @@ import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement"; import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, + isSnoozedForNotification, type ThreadNotificationKind, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -300,10 +301,12 @@ export function startThreadNotifications( entry.reconnecting = false; entry.previous = next; entry.gate.retain(new Set(next.keys())); + const now = Date.now(); for (const [prior, state] of changed) { entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, quiet: state.raw.latestRunStopRequested === true, + snoozed: isSnoozedForNotification(state.thread, state.completedAt, now), }); } };