1// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries.
2import { ThreadId } from "@t3tools/contracts";
3import { TurnId } from "@t3tools/contracts";
4import { describe, expect, it } from "vite-plus/test";
5
6import {
7 canSnooze,
8 effectiveSnoozed,
9 hasQueuedTurnStart,
10 resolveSnoozePresets,
11 snoozeWakeLabel,
12 threadRaisedHandWhileSnoozed,
13 threadWokeAt,
14 type ThreadSnoozeShell,
15} from "./threadSettled.ts";
16
17const NOW = "2026-04-10T12:00:00.000Z";
18const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
19const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";
20const PAST_WAKE = "2026-04-10T10:00:00.000Z";
21
22function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
23 return new Date(year, month - 1, day, hour, minute, 0, 0);
24}
25
26function makeShell(input: {
27 readonly snoozedUntil?: string | null;
28 readonly snoozedAt?: string | null;
29 readonly sessionStatus?: "starting" | "running" | "ready" | "error";
30 readonly pending?: "approval" | "user-input";
31 readonly turnCompletedAt?: string | null;
32}): ThreadSnoozeShell {
33 const threadId = ThreadId.make("thread-1");
34 return {
35 snoozedUntil: input.snoozedUntil ?? null,
36 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null),
37 hasPendingApprovals: input.pending === "approval",
38 hasPendingUserInput: input.pending === "user-input",
39 session:
40 input.sessionStatus === undefined
41 ? null
42 : {
43 threadId,
44 status: input.sessionStatus,
45 providerName: "Codex",
46 runtimeMode: "full-access",
47 activeTurnId: null,
48 lastError: input.sessionStatus === "error" ? "boom" : null,
49 updatedAt: "2026-04-10T11:00:00.000Z",
50 },
51 latestTurn:
52 input.turnCompletedAt === undefined
53 ? null
54 : {
55 turnId: TurnId.make("turn-1"),
56 state: "completed",
57 requestedAt: SNOOZED_AT,
58 startedAt: null,
59 completedAt: input.turnCompletedAt,
60 assistantMessageId: null,
61 },
62 };
63}
64
65type QueuedTurnShell = Parameters<typeof hasQueuedTurnStart>[0];
66
67function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell {
68 return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides };
69}
70
71describe("effectiveSnoozed", () => {
72 it("hides a thread whose wake time is in the future", () => {
73 expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true);
74 });
75
76 it("stops classifying as snoozed once the wake time passes (timer wake, no event)", () => {
77 expect(effectiveSnoozed(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(false);
78 });
79
80 it("never snoozes a thread with no snooze state", () => {
81 expect(effectiveSnoozed(makeShell({}), { now: NOW })).toBe(false);
82 });
83
84 it("never hides on malformed wake data", () => {
85 expect(effectiveSnoozed(makeShell({ snoozedUntil: "not-a-date" }), { now: NOW })).toBe(false);
86 });
87
88 it("wakes early when the agent is blocked on the user", () => {
89 expect(
90 effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" }), {
91 now: NOW,
92 }),
93 ).toBe(false);
94 expect(
95 effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" }), {
96 now: NOW,
97 }),
98 ).toBe(false);
99 });
100
101 it("wakes early on a failure that happened after the snooze", () => {
102 // makeShell stamps session.updatedAt at 11:00, after SNOOZED_AT (9:00).
103 expect(
104 effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), {
105 now: NOW,
106 }),
107 ).toBe(false);
108 });
109
110 it("stays snoozed when the failure predates the snooze — the user saw it", () => {
111 expect(
112 effectiveSnoozed(
113 makeShell({
114 snoozedUntil: FUTURE_WAKE,
115 sessionStatus: "error",
116 // Snoozed AFTER the error's status edge.
117 snoozedAt: "2026-04-10T11:30:00.000Z",
118 }),
119 { now: NOW },
120 ),
121 ).toBe(true);
122 });
123
124 it("stays snoozed while the session keeps working — snooze never pauses the agent", () => {
125 expect(
126 effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), {
127 now: NOW,
128 }),
129 ).toBe(true);
130 });
131
132 it("wakes early when a run completes after the snooze was set", () => {
133 expect(
134 effectiveSnoozed(
135 makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
136 { now: NOW },
137 ),
138 ).toBe(false);
139 });
140
141 it("ignores runs that completed before the snooze — the user saw that result", () => {
142 expect(
143 effectiveSnoozed(
144 makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T08:00:00.000Z" }),
145 { now: NOW },
146 ),
147 ).toBe(true);
148 });
149});
150
151describe("threadRaisedHandWhileSnoozed", () => {
152 it("is false for a quiet snoozed thread", () => {
153 expect(threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }))).toBe(false);
154 });
155
156 it("is true for approvals, input, and failures", () => {
157 expect(
158 threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" })),
159 ).toBe(true);
160 expect(
161 threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" })),
162 ).toBe(true);
163 expect(
164 threadRaisedHandWhileSnoozed(
165 makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }),
166 ),
167 ).toBe(true);
168 });
169});
170
171describe("canSnooze", () => {
172 it("allows snoozing quiet and working threads alike", () => {
173 expect(canSnooze({ ...makeShell({}), latestUserMessageAt: null }, { now: NOW })).toBe(true);
174 expect(
175 canSnooze(
176 { ...makeShell({ sessionStatus: "running" }), latestUserMessageAt: null },
177 { now: NOW },
178 ),
179 ).toBe(true);
180 });
181
182 it("refuses blocked-on-you work", () => {
183 expect(
184 canSnooze({ ...makeShell({ pending: "approval" }), latestUserMessageAt: null }, { now: NOW }),
185 ).toBe(false);
186 expect(
187 canSnooze(
188 { ...makeShell({ pending: "user-input" }), latestUserMessageAt: null },
189 { now: NOW },
190 ),
191 ).toBe(false);
192 expect(
193 canSnooze(
194 { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" },
195 { now: NOW },
196 ),
197 ).toBe(false);
198 });
199
200 it("refuses a queued turn start — same invisible-pending-work rule as settle", () => {
201 // Fresh user message, no turn has adopted it, within the grace window.
202 expect(
203 canSnooze(
204 { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:59:30.000Z" },
205 { now: NOW },
206 ),
207 ).toBe(false);
208 // Outside the grace window the message is stale data, not queued work.
209 expect(
210 canSnooze(
211 { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:00:00.000Z" },
212 { now: NOW },
213 ),
214 ).toBe(true);
215 });
216});
217
218describe("hasQueuedTurnStart", () => {
219 it("expires queued state after two minutes", () => {
220 const thread = makeQueuedTurnShell({
221 latestUserMessageAt: "2026-04-10T11:57:59.000Z",
222 });
223 expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false);
224 });
225
226 it("clears queued state when a turn adopts the message or the session fails", () => {
227 const messageAt = "2026-04-10T11:59:00.000Z";
228 const adopted = makeQueuedTurnShell({
229 latestUserMessageAt: messageAt,
230 latestTurn: {
231 turnId: TurnId.make("turn-adopted"),
232 state: "running",
233 requestedAt: messageAt,
234 startedAt: null,
235 completedAt: null,
236 assistantMessageId: null,
237 },
238 });
239 const failed = makeQueuedTurnShell({
240 latestUserMessageAt: messageAt,
241 session: {
242 threadId: ThreadId.make("thread-failed"),
243 status: "error",
244 providerName: "Codex",
245 runtimeMode: "full-access",
246 activeTurnId: null,
247 lastError: "failed",
248 updatedAt: NOW,
249 },
250 });
251 expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false);
252 expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false);
253 });
254
255 it("bounds future client clock skew", () => {
256 const farAhead = makeQueuedTurnShell({
257 latestUserMessageAt: "2026-04-10T12:03:00.000Z",
258 });
259 const slightlyAhead = makeQueuedTurnShell({
260 latestUserMessageAt: "2026-04-10T12:01:00.000Z",
261 });
262 expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false);
263 expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true);
264 });
265});
266
267describe("threadWokeAt", () => {
268 it("is null for never-snoozed and still-snoozed threads", () => {
269 expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null);
270 expect(threadWokeAt(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(null);
271 });
272
273 it("reports the wake time for a timer wake", () => {
274 expect(threadWokeAt(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(PAST_WAKE);
275 });
276
277 it("reports the completion time for an early run-completed wake", () => {
278 expect(
279 threadWokeAt(
280 makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }),
281 { now: NOW },
282 ),
283 ).toBe("2026-04-10T10:30:00.000Z");
284 });
285
286 it("falls back to session activity for blocked/failed early wakes", () => {
287 expect(
288 threadWokeAt(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), {
289 now: NOW,
290 }),
291 ).toBe("2026-04-10T11:00:00.000Z");
292 });
293
294 it("keeps the early wake authoritative after the scheduled time passes", () => {
295 // Woke early at 10:30 via run-completed; the scheduled wake (PAST_WAKE
296 // 10:00 relative to a later now) has ALSO passed. Reporting the
297 // scheduled time would resurface a Woke pill the user already cleared
298 // by visiting between the early wake and now.
299 expect(
300 threadWokeAt(
301 makeShell({ snoozedUntil: PAST_WAKE, turnCompletedAt: "2026-04-10T09:30:00.000Z" }),
302 { now: NOW },
303 ),
304 ).toBe("2026-04-10T09:30:00.000Z");
305 });
306});
307
308describe("snoozeWakeLabel", () => {
309 const now = "2026-06-02T00:00:00.000Z";
310
311 it("formats remaining time coarsely, rounding up", () => {
312 expect(snoozeWakeLabel("2026-06-02T00:30:00.000Z", { now })).toBe("30m");
313 expect(snoozeWakeLabel("2026-06-02T01:30:00.000Z", { now })).toBe("2h");
314 expect(snoozeWakeLabel("2026-06-03T02:00:00.000Z", { now })).toBe("2d");
315 });
316
317 it("never reads zero or negative while still snoozed", () => {
318 expect(snoozeWakeLabel("2026-06-02T00:00:30.000Z", { now })).toBe("1m");
319 expect(snoozeWakeLabel("2026-06-01T23:59:59.000Z", { now })).toBe("now");
320 expect(snoozeWakeLabel("not-a-date", { now })).toBe("now");
321 expect(snoozeWakeLabel("2026-06-02T09:00:00.000Z", { now: "bad" })).toBe("now");
322 });
323});
324
325describe("resolveSnoozePresets", () => {
326 it("offers the shared desktop and mobile choices", () => {
327 const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10));
328 expect(presets.map((preset) => preset.id)).toEqual([
329 "hour",
330 "three-hours",
331 "evening",
332 "tomorrow",
333 "next-week",
334 ]);
335 expect(presets.find((preset) => preset.id === "three-hours")?.snoozedUntil).toBe(
336 localDate(2026, 4, 8, 13).toISOString(),
337 );
338 expect(presets.find((preset) => preset.id === "three-hours")?.label).toBe("In 3 hours");
339 expect(presets.find((preset) => preset.id === "evening")?.label).toBe("This evening");
340 expect(
341 new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil).getHours(),
342 ).toBe(9);
343 });
344
345 it("drops the evening choice once evening is near or past", () => {
346 expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([
347 "hour",
348 "three-hours",
349 "tomorrow",
350 "next-week",
351 ]);
352 });
353
354 it("puts next week on the following Monday", () => {
355 const nextWeek = new Date(
356 resolveSnoozePresets(localDate(2026, 4, 6, 10)).find((preset) => preset.id === "next-week")!
357 .snoozedUntil,
358 );
359 expect(nextWeek.getDay()).toBe(1);
360 expect(nextWeek.getDate()).toBe(13);
361 });
362
363 it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => {
364 // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00.
365 const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1));
366 expect(presets.map((preset) => preset.id)).toEqual([
367 "hour",
368 "three-hours",
369 "evening",
370 "tomorrow",
371 ]);
372 const tomorrow = new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil);
373 expect(tomorrow.getDay()).toBe(1);
374 });
375});