Skip to content
Closed
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
4 changes: 2 additions & 2 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1200,8 +1200,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
// cleared here: snooze never pauses the agent, so its session starting
// or erroring is not the user re-engaging. Blocked/failed work still
// surfaces immediately — effectiveSnoozed refuses to classify a thread
// with a raised hand (approval / input / failure / fresh completion)
// as snoozed, without spending the return ticket.
// with a raised hand (approval / input / fresh failure) as snoozed,
// without spending the return ticket. Completion is not a raised hand.
const isSessionActivity =
command.session.status === "starting" || command.session.status === "running";
// Real activity resets ANY override (settled wakes, active unpins).
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -804,10 +804,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// A woken thread reappears at its original position (the sort is
// deliberately static), so the pill has to carry the weight. Snoozing is
// an explicit act, so the pill clears only when the user re-engages:
// reading a completion-triggered wake, clicking the pill, sending a
// message, settling, archiving, or a change request state that settles the
// thread. Timer wakes survive a mere visit. An unparseable visit timestamp
// counts as never-visited, so corrupt local data cannot eat the wake signal.
// visiting after a blocking early wake (approval / user-input / fresh
// error), clicking the pill, sending a message, settling, archiving, or a
// change request state that settles the thread. Timer wakes survive a mere
// visit. An unparseable visit timestamp counts as never-visited, so
// corrupt local data cannot eat the wake signal.
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
Expand Down
30 changes: 7 additions & 23 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,13 @@ export type ThreadSnoozeShell = Pick<
>;

/**
* A snoozed thread "raises its hand" when something happens that outranks
* the user's snooze: the agent is blocked on them (approval / user input),
* the session failed, or a run completed after the snooze was set — the
* v1 taste of event-based snooze ("something happened" wakes early).
* Raising a hand never clears the server-side snooze fields; it only stops
* the thread from CLASSIFYING as snoozed, exactly like blocked work and
* effectiveSettled.
* A snoozed thread "raises its hand" when something user-blocking outranks
* the chosen wake time: the agent is waiting on them (approval / user
* input), or the session failed after the snooze was set. Snooze is an
* attention overlay — work finishing updates execution state only and does
* not surface the thread. Raising a hand never clears the server-side
* snooze fields; it only stops the thread from CLASSIFYING as snoozed,
* exactly like blocked work and effectiveSettled.
*/
export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
Expand All @@ -136,14 +136,6 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean
) {
return true;
}
if (
shell.snoozedAt != null &&
shell.latestTurn?.state === "completed" &&
shell.latestTurn.completedAt != null &&
Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt)
) {
return true;
}
return false;
}

Expand Down Expand Up @@ -209,14 +201,6 @@ export function threadWokeAt(
// indicator the user already cleared by visiting (snoozedUntil is newer
// than that visit's lastVisitedAt).
if (threadRaisedHandWhileSnoozed(shell)) {
if (
shell.snoozedAt != null &&
shell.latestTurn?.state === "completed" &&
shell.latestTurn.completedAt != null &&
Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt)
) {
return shell.latestTurn.completedAt;
}
return shell.session?.updatedAt ?? shell.snoozedAt ?? null;
}
// No raised hand: woke iff the timer elapsed (still-snoozed → null).
Expand Down
55 changes: 42 additions & 13 deletions packages/client-runtime/src/state/threadSnoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,13 @@ describe("effectiveSnoozed", () => {
).toBe(true);
});

it("wakes early when a run completes after the snooze was set", () => {
it("stays snoozed when a run completes after the snooze was set", () => {
expect(
effectiveSnoozed(
makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
{ now: NOW },
),
).toBe(false);
).toBe(true);
});

it("ignores runs that completed before the snooze — the user saw that result", () => {
Expand All @@ -139,6 +139,19 @@ describe("effectiveSnoozed", () => {
),
).toBe(true);
});

it("still wakes on a pending approval after work has finished", () => {
expect(
effectiveSnoozed(
makeShell({
snoozedUntil: FUTURE_WAKE,
pending: "approval",
turnCompletedAt: "2026-04-10T10:30:00.000Z",
}),
{ now: NOW },
),
).toBe(false);
});
});

describe("threadRaisedHandWhileSnoozed", () => {
Expand All @@ -159,6 +172,14 @@ describe("threadRaisedHandWhileSnoozed", () => {
),
).toBe(true);
});

it("does not treat turn completion as a raised hand", () => {
expect(
threadRaisedHandWhileSnoozed(
makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
),
).toBe(false);
});
});

describe("canSnooze", () => {
Expand Down Expand Up @@ -212,13 +233,22 @@ describe("threadWokeAt", () => {
expect(threadWokeAt(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(PAST_WAKE);
});

it("reports the completion time for an early run-completed wake", () => {
it("does not treat a later completion as an early wake", () => {
expect(
threadWokeAt(
makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
{ now: NOW },
),
).toBe("2026-04-10T10:30:00.000Z");
).toBe(null);
});

it("reports the scheduled wake time even if a turn completed after the snooze", () => {
expect(
threadWokeAt(
makeShell({ snoozedUntil: PAST_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
{ now: NOW },
),
).toBe(PAST_WAKE);
});

it("falls back to session activity for blocked/failed early wakes", () => {
Expand All @@ -230,16 +260,15 @@ describe("threadWokeAt", () => {
});

it("keeps the early wake authoritative after the scheduled time passes", () => {
// Woke early at 10:30 via run-completed; the scheduled wake (PAST_WAKE
// 10:00 relative to a later now) has ALSO passed. Reporting the
// scheduled time would resurface a Woke pill the user already cleared
// by visiting between the early wake and now.
// Woke early at 11:00 via a fresh session error; the scheduled wake
// (PAST_WAKE 10:00 relative to a later now) has ALSO passed. Reporting
// the scheduled time would resurface a Woke pill the user already
// cleared by visiting between the early wake and now.
expect(
threadWokeAt(
makeShell({ snoozedUntil: PAST_WAKE, turnCompletedAt: "2026-04-10T09:30:00.000Z" }),
{ now: NOW },
),
).toBe("2026-04-10T09:30:00.000Z");
threadWokeAt(makeShell({ snoozedUntil: PAST_WAKE, sessionStatus: "error" }), {
now: NOW,
}),
).toBe("2026-04-10T11:00:00.000Z");
});
});

Expand Down
Loading