diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index b2daad62681e9..a74732c131174 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -5,6 +5,7 @@ import { MessageId, type OrchestrationV2Command, type OrchestrationV2Run, + type OrchestrationV2ServerCommand, type OrchestrationV2ProviderThread, ProviderThreadId, ProviderDriverKind, @@ -565,7 +566,8 @@ it.effect.each([ const projectId = ProjectId.make("project:thread-management:settle-after-run"); const threadId = ThreadId.make("thread:thread-management:settle-after-run"); const runId = RunId.make("run:thread-management:settle-after-run"); - const dispatched: Array = []; + const completedAt = DateTime.makeUnsafe("2026-09-13T07:00:01.000Z"); + const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( @@ -575,11 +577,11 @@ it.effect.each([ getThreadRecords: () => Effect.succeed({ thread: { id: threadId, projectId, deletedAt: null }, - runs: [{ id: runId, status }], + runs: [{ id: runId, status, completedAt }], } as unknown as OrchestrationV2ThreadProjection), dispatch: (command) => Effect.sync(() => { - dispatched.push(command.type); + dispatched.push(command); return { sequence: 1, storedEvents: [] } as never; }), }), @@ -593,6 +595,9 @@ it.effect.each([ yield* service.settleAfterRun({ projectId, threadId, runId }); - expect(dispatched).toEqual(settles ? ["thread.settle"] : []); + // Stamped at the completion, so clients still notify it like an auto-settle. + expect(dispatched).toMatchObject( + settles ? [{ type: "thread.settle", settledAt: completedAt }] : [], + ); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index cfbbd4a68a51d..93d8c5a9bbf72 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -996,6 +996,8 @@ const make = Effect.gen(function* () { type: "thread.settle", commandId: CommandId.make(`server:settle-after-run:${input.runId}`), threadId: input.threadId, + // Like auto-settles, stamp the completion: clients only silence settles stamped after it. + ...(run.completedAt === null ? {} : { settledAt: run.completedAt }), }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 89128ff69ac55..b7a0a02404cfa 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -105,6 +105,7 @@ const thread = { id: "thread", title: "Test thread", archivedAt: null as string | null, + settledAt: null as string | null, hasPendingApprovals: false, hasPendingUserInput: false, session: null as { status: string } | null, @@ -162,8 +163,8 @@ function toV2ThreadShell(input: typeof thread) { ? DateTime.makeUnsafe(latestTurn.completedAt) : undefined, archivedAt: input.archivedAt ? DateTime.makeUnsafe(input.archivedAt) : null, - settledOverride: null, - settledAt: null, + settledOverride: input.settledAt ? "settled" : null, + settledAt: input.settledAt ? DateTime.makeUnsafe(input.settledAt) : null, lastVisitedAt: null, deletedAt: null, }; @@ -260,3 +261,35 @@ it("holds a completion through a continuation gap and notifies once it settles", await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); + +it("does not notify a completion the user settles during the grace", async () => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const latestTurn = { + turnId: "turn", + state: "completed", + completedAt: "2026-09-13T07:00:01.000Z", + }; + state.shells.set("one", shell({ latestTurn })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS / 2); + state.shells.set("one", shell({ latestTurn, settledAt: "2026-09-13T07:00:02.000Z" })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual([]); +}); + +it("still notifies a completion that auto-settles during the grace", async () => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const latestTurn = { + turnId: "turn", + state: "completed", + completedAt: "2026-09-13T07:00:01.000Z", + }; + state.shells.set("one", shell({ latestTurn })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS / 2); + state.shells.set("one", shell({ latestTurn, settledAt: latestTurn.completedAt })); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..cfaff628fbb82 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -40,7 +40,7 @@ export function createThreadNotificationGate( ): void { if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); if (!next.settled || options.archived || options.quiet) cancel(key); - // The first sighting, archived threads, and stopped or silent runs never notify completion. + // The first sighting, archived threads, and stopped, silent, or user-settled runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); if (!prior || options.archived) return; if (next.attention && next.attention !== prior.attention) { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fa4fdbb672faa..ae1156550d14b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -224,9 +224,15 @@ export function startThreadNotifications( entry.previous = next; entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { + const { settledOverride, settledAt } = state.thread; + // Auto and agent settles stamp the run's own completion; a later stamp is the user dismissing it. + const userSettled = + settledOverride === "settled" && + state.completedAt !== null && + Date.parse(settledAt ?? "") > state.completedAt; entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, - quiet: state.raw.latestRunStopRequested === true, + quiet: state.raw.latestRunStopRequested === true || userSettled, }); } };