diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a003ade1ae8f..4b95e1c8693d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1108,7 +1108,8 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" - >; + > & + Pick; type ShellThreadRow = { readonly thread_id: string; @@ -8788,6 +8789,7 @@ export const layer: Layer.Layer = archivedAt: state.thread.archivedAt, snoozedUntil: state.thread.snoozedUntil ?? null, snoozedAt: state.thread.snoozedAt ?? null, + lastSnoozeWakeAt: state.thread.lastSnoozeWakeAt ?? null, pendingGuestMessageCount: pendingGuestMessageCount(state.thread.guestShare), activeRunId: state.activeRunId, latestRunId: state.latestRunId, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 10388e99fe7c..b06bdec7dc78 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -49,7 +49,10 @@ const SECOND_THREAD_ID = ThreadId.make("relay-thread-2"); const PROJECT_ID = ProjectId.make("relay-project"); const NOW = "2026-09-04T12:00:00.000Z"; -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { +type AwarenessShell = OrchestrationV2ThreadShell & + Pick; + +function shell(overrides: Partial = {}): AwarenessShell { return { id: THREAD_ID, projectId: PROJECT_ID, @@ -814,6 +817,43 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each(["1 second", "5 seconds"] as const)( + "does not alert a completion the snooze hid when woken %s after it", + (beforeWake) => + Effect.gen(function* () { + const snoozed = { + snoozedUntil: DateTime.makeUnsafe("2999-01-01T00:00:00.000Z"), + snoozedAt: DateTime.makeUnsafe(NOW), + }; + const completed = { + status: "completed" as const, + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"), + }; + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set(currentShell, shell(snoozed)); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ ...snoozed, ...completed })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust(beforeWake); + yield* relay.drain; + + yield* Ref.set( + currentShell, + shell({ + ...completed, + lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:02: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("publishes a cleared thread's tombstone once while it stays cleared", () => 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 ef2e34799742..3c9dcf5a0519 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -376,6 +376,13 @@ function snoozedCompletion( ): boolean { if (state?.phase !== "completed" || thread.latestRunCompletedAt == null) return false; const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + // Wake clears the snooze fields, so a completion that ended before the wake was hidden by it. + if ( + thread.lastSnoozeWakeAt != null && + completedAt <= DateTime.toEpochMillis(thread.lastSnoozeWakeAt) + ) { + return true; + } if (thread.snoozedAt != null && completedAt < DateTime.toEpochMillis(thread.snoozedAt)) { return false; }