Open History to see the last commit
1// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries.
2import * as DateTime from "effect/DateTime";
3import { RuntimeRequestId } from "@t3tools/contracts";
4import { ThreadId } from "@t3tools/contracts";
5import { TurnId } from "@t3tools/contracts";
6import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
7import { describe, expect, it } from "vite-plus/test";
8
9import {
10 canSnooze,
11 effectiveSnoozed,
12 hasQueuedTurnStart,
13 pickSnoozePreset,
14 resolveSnoozePresets,
15 threadRaisedHandWhileSnoozed,
16 threadWokeAt,
17 isThreadWokeUnseen,
18 type ThreadSnoozeShell,
19} from "./threadSettled.ts";
20
21const NOW = "2026-04-10T12:00:00.000Z";
22const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
23const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";
24const PAST_WAKE = "2026-04-10T10:00:00.000Z";
25
26function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
27 return new Date(year, month - 1, day, hour, minute, 0, 0);
28}
29
30function makeShell(input: {
31 readonly snoozedUntil?: string | null;
32 readonly snoozedAt?: string | null;
33 readonly sessionStatus?: "starting" | "running" | "ready" | "error";
34 readonly pending?: "approval" | "user-input";
35 readonly pendingUserInputAt?: string | null;
36 readonly turnRequestedAt?: string | null;
37 readonly turnCompletedAt?: string | null;
38}): ThreadSnoozeShell {
39 const threadId = ThreadId.make("thread-1");
40 return {
41 snoozedUntil: input.snoozedUntil ?? null,
42 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null),
43 hasPendingApprovals: input.pending === "approval",
44 hasPendingUserInput: input.pending === "user-input",
45 pendingUserInputAt: input.pendingUserInputAt ?? null,
46 session:
47 input.sessionStatus === undefined
48 ? null
49 : {
50 threadId,
51 status: input.sessionStatus,
52 providerName: "Codex",
53 runtimeMode: "full-access",
54 activeTurnId: null,
55 lastError: input.sessionStatus === "error" ? "boom" : null,
56 updatedAt: "2026-04-10T11:00:00.000Z",
57 },
58 latestTurn:
59 input.turnCompletedAt === undefined
60 ? null
61 : {
62 turnId: TurnId.make("turn-1"),
63 state: "completed",
64 requestedAt: input.turnRequestedAt === undefined ? SNOOZED_AT : input.turnRequestedAt,
65 startedAt: null,
66 completedAt: input.turnCompletedAt,
67 assistantMessageId: null,
68 },
69 };
70}
71
72type QueuedTurnShell = Parameters<typeof hasQueuedTurnStart>[0];
73
74function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell {
75 return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides };
76}
77
78describe("effectiveSnoozed", () => {
79 it("never hides on malformed wake data", () => {
80 expect(effectiveSnoozed(makeShell({ snoozedUntil: "not-a-date" }), { now: NOW })).toBe(false);
81 });
82
83 it("defers an existing question until the timer, but wakes for a new question", () => {
84 const shell = makeShell({
85 snoozedUntil: FUTURE_WAKE,
86 pending: "user-input",
87 pendingUserInputAt: SNOOZED_AT,
88 });
89 expect(effectiveSnoozed(shell, { now: NOW })).toBe(true);
90 expect(threadWokeAt(shell, { now: NOW })).toBeNull();
91 expect(effectiveSnoozed(shell, { now: FUTURE_WAKE })).toBe(false);
92 expect(threadWokeAt(shell, { now: FUTURE_WAKE })).toBe(FUTURE_WAKE);
93
94 const newQuestion = { ...shell, pendingUserInputAt: "2026-04-10T10:00:00.000Z" };
95 expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false);
96 expect(threadWokeAt(newQuestion, { now: NOW })).toBe(newQuestion.pendingUserInputAt);
97 expect(effectiveSnoozed({ ...shell, pendingUserInputAt: "invalid" }, { now: NOW })).toBe(false);
98 });
99
100 it.each([false, true])(
101 "snoozes existing source questions with blocking=%s and wakes for newer ones",
102 (blocking) => {
103 const shell: ThreadSnoozeShell = {
104 ...makeShell({ snoozedUntil: FUTURE_WAKE }),
105 source: {
106 pendingRuntimeRequest: {
107 id: RuntimeRequestId.make("question"),
108 kind: "user_input",
109 blocking,
110 createdAt: DateTime.makeUnsafe(SNOOZED_AT),
111 },
112 },
113 };
114 expect(canSnooze(shell, { now: NOW })).toBe(true);
115 expect(effectiveSnoozed(shell, { now: NOW })).toBe(true);
116 const newQuestion = {
117 ...shell,
118 source: {
119 pendingRuntimeRequest: {
120 ...shell.source!.pendingRuntimeRequest!,
121 createdAt: DateTime.makeUnsafe("2026-04-10T10:00:00.000Z"),
122 },
123 },
124 };
125 expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false);
126 expect(threadWokeAt(newQuestion, { now: NOW })).toBe("2026-04-10T10:00:00.000Z");
127 const approval = {
128 ...shell,
129 source: {
130 pendingRuntimeRequest: {
131 ...shell.source!.pendingRuntimeRequest!,
132 kind: "command" as const,
133 },
134 },
135 };
136 expect(canSnooze(approval, { now: NOW })).toBe(false);
137 expect(effectiveSnoozed(approval, { now: NOW })).toBe(false);
138 },
139 );
140
141 it.each([
142 {
143 reason: "stale failure",
144 completedAt: "2026-04-10T08:00:00.000Z",
145 pending: undefined,
146 wokeAt: null,
147 },
148 {
149 reason: "failure without a completion timestamp",
150 completedAt: null,
151 pending: undefined,
152 wokeAt: null,
153 },
154 {
155 reason: "approval without a failure completion timestamp",
156 completedAt: null,
157 pending: "approval",
158 wokeAt: "2026-04-10T11:00:00.000Z",
159 },
160 {
161 reason: "fresh failure",
162 completedAt: "2026-04-10T10:00:00.000Z",
163 pending: undefined,
164 wokeAt: "2026-04-10T10:00:00.000Z",
165 },
166 {
167 reason: "approval after a stale failure",
168 completedAt: "2026-04-10T08:00:00.000Z",
169 pending: "approval",
170 wokeAt: "2026-04-10T11:00:00.000Z",
171 },
172 {
173 reason: "input after a stale failure",
174 completedAt: "2026-04-10T08:00:00.000Z",
175 pending: "user-input",
176 wokeAt: "2026-04-10T11:00:00.000Z",
177 },
178 ] as const)(
179 "uses the wake event time for $reason despite newer metadata",
180 ({ completedAt, pending, wokeAt }) => {
181 const shell: ThreadSnoozeShell = {
182 ...makeShell({ snoozedUntil: FUTURE_WAKE }),
183 hasPendingApprovals: pending === "approval",
184 hasPendingUserInput: pending === "user-input",
185 runtime: { status: "failed", updatedAt: "2026-04-10T11:00:00.000Z" },
186 latestRun: { status: "failed", requestedAt: "2026-04-10T07:00:00.000Z", completedAt },
187 };
188 expect(effectiveSnoozed(shell, { now: NOW })).toBe(wokeAt === null);
189 expect(threadWokeAt(shell, { now: NOW })).toBe(wokeAt);
190 },
191 );
192
193 it("stays snoozed while the session keeps working — snooze never pauses the agent", () => {
194 expect(
195 effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), {
196 now: NOW,
197 }),
198 ).toBe(true);
199 });
200
201 it("stays snoozed when a run requested after the snooze completes", () => {
202 const shell = {
203 ...makeShell({ snoozedUntil: FUTURE_WAKE }),
204 latestRun: {
205 status: "completed",
206 requestedAt: "2026-04-10T09:00:00.001Z",
207 startedAt: "2026-04-10T09:15:00.000Z",
208 completedAt: "2026-04-10T10:30:00.000Z",
209 },
210 };
211
212 expect(effectiveSnoozed(shell, { now: NOW })).toBe(true);
213 expect(threadWokeAt(shell, { now: NOW })).toBe(null);
214 });
215});
216
217describe("threadRaisedHandWhileSnoozed", () => {
218 it("is true for approvals, input, and failures", () => {
219 expect(
220 threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" })),
221 ).toBe(true);
222 expect(
223 threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" })),
224 ).toBe(true);
225 expect(
226 threadRaisedHandWhileSnoozed(
227 makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }),
228 ),
229 ).toBe(true);
230 });
231});
232
233describe("canSnooze", () => {
234 it("allows deferring user input while refusing approvals and subagent requests", () => {
235 expect(
236 canSnooze({ ...makeShell({ pending: "approval" }), latestUserMessageAt: null }, { now: NOW }),
237 ).toBe(false);
238 expect(
239 canSnooze(
240 { ...makeShell({ pending: "user-input" }), latestUserMessageAt: null },
241 { now: NOW },
242 ),
243 ).toBe(true);
244 expect(
245 canSnooze(
246 { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" },
247 { now: NOW },
248 ),
249 ).toBe(false);
250 });
251
252 it("refuses a queued turn start — same invisible-pending-work rule as settle", () => {
253 expect(
254 canSnooze(
255 { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:59:30.000Z" },
256 { now: NOW },
257 ),
258 ).toBe(false);
259 expect(
260 canSnooze(
261 { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:00:00.000Z" },
262 { now: NOW },
263 ),
264 ).toBe(true);
265 });
266});
267
268describe("hasQueuedTurnStart", () => {
269 it("clears queued state when a turn adopts the message or the session fails", () => {
270 const messageAt = "2026-04-10T11:59:00.000Z";
271 const adopted = makeQueuedTurnShell({
272 latestUserMessageAt: messageAt,
273 latestTurn: {
274 turnId: TurnId.make("turn-adopted"),
275 state: "running",
276 requestedAt: messageAt,
277 startedAt: null,
278 completedAt: null,
279 assistantMessageId: null,
280 },
281 });
282 const failed = makeQueuedTurnShell({
283 latestUserMessageAt: messageAt,
284 session: {
285 threadId: ThreadId.make("thread-failed"),
286 status: "error",
287 providerName: "Codex",
288 runtimeMode: "full-access",
289 activeTurnId: null,
290 lastError: "failed",
291 updatedAt: NOW,
292 },
293 });
294 expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false);
295 expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false);
296 });
297});
298
299describe("threadWokeAt", () => {
300 it("reports the timer wake even after an already-requested run completes", () => {
301 expect(threadWokeAt(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(PAST_WAKE);
302 const shell = makeShell({
303 snoozedUntil: PAST_WAKE,
304 turnCompletedAt: "2026-04-10T09:30:00.000Z",
305 });
306 expect(effectiveSnoozed(shell, { now: NOW })).toBe(false);
307 expect(threadWokeAt(shell, { now: NOW })).toBe(PAST_WAKE);
308 });
309
310 it.each([
311 { reason: "failure", sessionStatus: "error" },
312 { reason: "approval", sessionStatus: "ready", pending: "approval" },
313 { reason: "user input", sessionStatus: "ready", pending: "user-input" },
314 ] as const)("reports $reason activity instead of an already-requested completion", (activity) => {
315 const shell = makeShell({
316 ...activity,
317 snoozedUntil: FUTURE_WAKE,
318 turnCompletedAt: "2026-04-10T10:30:00.000Z",
319 });
320 expect(effectiveSnoozed(shell, { now: NOW })).toBe(false);
321 expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T11:00:00.000Z");
322 });
323
324 it.each(["failure", "subagent request"] as const)(
325 "keeps an old question from masking a newer %s wake",
326 (wake) => {
327 const shell = {
328 ...makeShell({
329 snoozedUntil: FUTURE_WAKE,
330 pending: "user-input",
331 pendingUserInputAt: SNOOZED_AT,
332 sessionStatus: wake === "failure" ? "error" : "running",
333 }),
334 pendingSubagentRequest: wake === "subagent request" ? ("user_input" as const) : null,
335 };
336 const lastVisitedAt = "2026-04-10T10:00:00.000Z";
337 expect(effectiveSnoozed(shell, { now: NOW })).toBe(false);
338 const wokeAt = threadWokeAt(shell, { now: NOW });
339 expect(wokeAt).toBe("2026-04-10T11:00:00.000Z");
340 expect(Date.parse(wokeAt!)).toBeGreaterThan(Date.parse(lastVisitedAt));
341 },
342 );
343
344 it.each([
345 ["error", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"],
346 ["failed", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"],
347 ["failed", "2026-04-10T11:30:00.000Z", "2026-04-10T11:30:00.000Z"],
348 ["running", "2026-04-10T10:00:00.000Z", "2026-04-10T10:00:00.000Z"],
349 ] as const)(
350 "uses the latest wake event for a %s runtime and a question at %s",
351 (status, pendingUserInputAt, expectedWakeAt) => {
352 const shell = {
353 ...makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input", pendingUserInputAt }),
354 runtime: { status, updatedAt: "2026-04-10T11:00:00.000Z" },
355 };
356 const lastVisitedAt = "2026-04-10T10:30:00.000Z";
357 const wokeAt = threadWokeAt(shell, { now: NOW });
358 expect(wokeAt).toBe(expectedWakeAt);
359 expect(Date.parse(wokeAt!) > Date.parse(lastVisitedAt)).toBe(status !== "running");
360 },
361 );
362
363 it("keeps the early wake authoritative after the scheduled time passes", () => {
364 const shell: ThreadSnoozeShell = {
365 ...makeShell({ snoozedUntil: PAST_WAKE }),
366 runtime: { status: "failed", updatedAt: "2026-04-10T11:00:00.000Z" },
367 latestRun: {
368 status: "failed",
369 requestedAt: "2026-04-10T09:15:00.000Z",
370 completedAt: "2026-04-10T09:30:00.000Z",
371 },
372 };
373 expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T09:30:00.000Z");
374 });
375});
376
377describe("resolveSnoozePresets", () => {
378 const formatTime = (date: Date) =>
379 `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`;
380 const menu = (now: Date, presets?: ReadonlyArray<SnoozePresetSetting>) =>
381 resolveSnoozePresets(now, { formatTime, ...(presets ? { presets } : {}) }).map(
382 (preset) => `${preset.label} ${preset.whenLabel}`,
383 );
384
385 it("lists custom presets soonest first and names the day when the label does not", () => {
386 // Wednesday 20:00.
387 expect(
388 menu(localDate(2026, 4, 8, 20), [
389 { kind: "at", day: "monday", time: "18:00" },
390 { kind: "at", day: "tomorrow", time: "21:00" },
391 { kind: "after", minutes: 360 },
392 { kind: "after", minutes: 180 },
393 ]),
394 ).toEqual(["In 3 hours 23:00", "In 6 hours Thu 2:00", "Tomorrow 21:00", "Next week Mon 18:00"]);
395 });
396
397 it("drops a today preset once its time passes and moves a weekday on that day to next week", () => {
398 // Monday 18:30.
399 const now = localDate(2026, 4, 13, 18, 30);
400 const presets = resolveSnoozePresets(now, {
401 presets: [
402 { kind: "at", day: "today", time: "18:00" },
403 { kind: "at", day: "monday", time: "09:00" },
404 ],
405 });
406 expect(presets.map((preset) => preset.id)).toEqual(["at:monday:09:00"]);
407 expect(new Date(presets[0]!.snoozedUntil)).toEqual(localDate(2026, 4, 20, 9));
408 });
409
410 it("names the real day gap: a Monday option on Sunday is Tomorrow, a weekday a week out is Next", () => {
411 const theo: ReadonlyArray<SnoozePresetSetting> = [
412 { kind: "at", day: "tomorrow", time: "21:00" },
413 { kind: "at", day: "monday", time: "18:00" },
414 { kind: "at", day: "friday", time: "17:00" },
415 ];
416 // Sunday 14:00.
417 expect(menu(localDate(2026, 8, 30, 14), theo)).toEqual([
418 "Tomorrow 18:00",
419 "Tomorrow 21:00",
420 "Friday 17:00",
421 ]);
422 // Friday 10:00: today's 17:00 is a week away.
423 expect(menu(localDate(2026, 9, 4, 10), theo)).toEqual([
424 "Tomorrow 21:00",
425 "Next week Mon 18:00",
426 "Next Friday 17:00",
427 ]);
428 });
429
430 it("shows one row when two presets wake at the same time", () => {
431 // Sunday, so Tomorrow 9:00 and Next week 9:00 are the same Monday.
432 expect(menu(localDate(2026, 8, 30, 7, 1))).toEqual([
433 "In 1 hour 8:01",
434 "In 3 hours 10:01",
435 "This evening 18:00",
436 "Tomorrow 9:00",
437 ]);
438 });
439
440 it("treats a duration and a set time in the same minute as one row", () => {
441 const now = localDate(2026, 4, 8, 15);
442 now.setSeconds(25);
443 expect(
444 menu(now, [
445 { kind: "after", minutes: 180 },
446 { kind: "at", day: "today", time: "18:00" },
447 ]),
448 ).toEqual(["In 3 hours 18:00"]);
449 });
450});
451
452describe("pickSnoozePreset", () => {
453 it("starts a duration when it is picked, even when the menu merged it with a set time", () => {
454 const presets: ReadonlyArray<SnoozePresetSetting> = [
455 { kind: "at", day: "today", time: "18:30" },
456 { kind: "after", minutes: 180 },
457 ];
458 // At 15:00 the two differ; at 15:30 "In 3 hours" lands on 18:30 and the menu shows one row.
459 const shown = resolveSnoozePresets(localDate(2026, 4, 8, 15), { presets });
460 const picked = pickSnoozePreset({
461 shown,
462 id: "after:180",
463 now: localDate(2026, 4, 8, 15, 30),
464 presets,
465 });
466 expect(picked?.snoozedUntil).toBe(localDate(2026, 4, 8, 18, 30).toISOString());
467 });
468
469 it("keeps a set time's shown wake when the day changes while the menu is open", () => {
470 // Sunday 23:59: "Tomorrow" means Monday 9:00, even if the pick lands after midnight.
471 const shown = resolveSnoozePresets(localDate(2026, 8, 30, 23, 59));
472 const picked = pickSnoozePreset({
473 shown,
474 id: "at:tomorrow:09:00",
475 now: localDate(2026, 8, 31, 0, 1),
476 });
477 expect(picked?.snoozedUntil).toBe(localDate(2026, 8, 31, 9).toISOString());
478 });
479});
480
481it("dismisses a wake at its timestamp without hiding a later wake", () => {
482 const firstWake = "2026-04-10T10:00:00.000Z";
483 const state = {
484 wokeAt: firstWake,
485 settled: false,
486 lastVisitedAt: null,
487 latestRunCompletedAt: null,
488 };
489 expect(isThreadWokeUnseen(state)).toBe(true);
490 expect(isThreadWokeUnseen({ ...state, lastVisitedAt: firstWake })).toBe(false);
491 expect(isThreadWokeUnseen({ ...state, lastVisitedAt: firstWake, wokeAt: NOW })).toBe(true);
492});