1import type { AnyRouter } from "@tanstack/react-router";
2import { EnvironmentId } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Option from "effect/Option";
5import { Atom, AtomRegistry } from "effect/reactivity";
6import { expect, it, vi } from "vite-plus/test";
7
8import { fakeTimers, stubGlobal } from "../../test/render";
9import { RELEASED_COMPLETION_GRACE_MS } from "./ThreadNotificationCoordinator.logic";
10
11const state = vi.hoisted(() => ({
12 mode: "notifications",
13 inApp: false,
14 toast: vi.fn(),
15 closeToast: vi.fn(),
16 shells: null as unknown as { set: (id: string, value: unknown) => void },
17 navigate: vi.fn(),
18 sound: vi.fn(),
19 badge: vi.fn(),
20 settingsListeners: new Set<() => void>(),
21}));
22vi.mock(import("./ui/toast"), async (original) => {
23 const actual = await original();
24 return {
25 ...actual,
26 toastManager: {
27 ...actual.toastManager,
28 add: state.toast as never,
29 close: state.closeToast as never,
30 },
31 };
32});
33vi.mock(import("../state/shell"), async (original) => {
34 const actual = await original();
35 return {
36 ...actual,
37 environmentShell: {
38 ...actual.environmentShell,
39 stateValueAtom: ((id: string) => shellAtom(id)) as never,
40 },
41 };
42});
43vi.mock(import("../state/presentation"), async (original) => {
44 const actual = await original();
45 return {
46 ...actual,
47 environmentSummaries: {
48 ...actual.environmentSummaries,
49 // Read lazily: the factory runs before this module's own bindings exist.
50 get environmentIdsAtom() {
51 return environmentIdsAtom as never;
52 },
53 },
54 };
55});
56vi.mock(import("../state/threads"), async (original) => {
57 const actual = await original();
58 return {
59 ...actual,
60 environmentThreadShells: {
61 ...actual.environmentThreadShells,
62 pendingSubagentRequestsAtom: (() => noRequestsAtom) as never,
63 get delegatedUnderParentAtom() {
64 return noDelegatedAtom as never;
65 },
66 },
67 };
68});
69vi.mock(import("../hooks/useSettings"), async (original) => ({
70 ...(await original()),
71 subscribeClientSettings: ((listener: () => void) => {
72 state.settingsListeners.add(listener);
73 return () => state.settingsListeners.delete(listener);
74 }) as never,
75 getClientSettings: (() => ({
76 notificationMode: state.mode,
77 inAppNotificationsEnabled: state.inApp,
78 })) as never,
79}));
80vi.mock(import("../threadNotifications"), async (original) => ({
81 ...(await original()),
82 playNotificationSound: state.sound,
83 unlockNotificationAudio: vi.fn(),
84 setNotificationBadge: state.badge,
85}));
86
87import { startThreadNotifications } from "./ThreadNotificationCoordinator";
88
89let registry: AtomRegistry.AtomRegistry;
90const shellAtom = Atom.family((_id: string) => Atom.make<unknown>(null));
91const environmentIdsAtom = Atom.make<ReadonlyArray<string>>([]);
92const noRequestsAtom = Atom.make(new Map());
93const noDelegatedAtom = Atom.make<ReadonlySet<string>>(new Set<string>());
94state.shells = { set: (id, value) => registry.set(shellAtom(id), value) };
95
96class TestNotification extends EventTarget {
97 static permission = "granted";
98 static sent: TestNotification[] = [];
99 close = vi.fn();
100 get tag() {
101 return this.options.tag ?? "";
102 }
103 constructor(
104 readonly title: string,
105 readonly options: NotificationOptions,
106 ) {
107 super();
108 TestNotification.sent.push(this);
109 }
110}
111
112const thread = {
113 id: "thread",
114 title: "Test thread",
115 archivedAt: null as string | null,
116 hasPendingApprovals: false,
117 hasPendingUserInput: false,
118 pendingRequestId: "request-1",
119 session: null as { status: string } | null,
120 latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null },
121};
122let focused = false;
123let visibility = "visible";
124
125const SHELL_NOW = DateTime.makeUnsafe("2026-09-13T07:00:00.000Z");
126
127function toV2ThreadShell(input: typeof thread) {
128 const latestTurn = input.latestTurn;
129 return {
130 id: input.id,
131 projectId: "project",
132 title: input.title,
133 providerInstanceId: "codex",
134 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
135 runtimeMode: "full-access",
136 interactionMode: "default",
137 branch: null,
138 worktreePath: null,
139 activeProviderThreadId: null,
140 lineage: {
141 rootThreadId: input.id,
142 parentThreadId: null,
143 relationshipToParent: null,
144 },
145 forkedFrom: null,
146 createdBy: "user",
147 creationSource: "web",
148 latestRunId: latestTurn.turnId,
149 activeRunId: null,
150 status:
151 latestTurn.state === "completed"
152 ? "completed"
153 : latestTurn.state === "error" || input.session?.status === "error"
154 ? "failed"
155 : "running",
156 pendingRuntimeRequest: input.hasPendingUserInput
157 ? { id: input.pendingRequestId, kind: "user_input", createdAt: SHELL_NOW }
158 : input.hasPendingApprovals
159 ? { id: input.pendingRequestId, kind: "command", createdAt: SHELL_NOW }
160 : null,
161 latestVisibleMessage: null,
162 latestUserMessageAt: null,
163 hasActionableProposedPlan: false,
164 itemCount: 0,
165 visibleItemCount: 0,
166 createdAt: SHELL_NOW,
167 updatedAt: SHELL_NOW,
168 latestRunRequestedAt: SHELL_NOW,
169 latestRunStartedAt: SHELL_NOW,
170 latestRunCompletedAt: latestTurn.completedAt
171 ? DateTime.makeUnsafe(latestTurn.completedAt)
172 : undefined,
173 archivedAt: input.archivedAt ? DateTime.makeUnsafe(input.archivedAt) : null,
174 settledOverride: null,
175 settledAt: null,
176 lastVisitedAt: null,
177 deletedAt: null,
178 };
179}
180
181function shell(overrides: Partial<typeof thread> = {}, ...others: Partial<typeof thread>[]) {
182 return {
183 status: "live",
184 snapshot: Option.some({
185 threads: [overrides, ...others].map((override) =>
186 toV2ThreadShell({ ...thread, ...override }),
187 ),
188 }),
189 };
190}
191// The mocked modules read `state` and the focus flags, so every test resets them before
192// starting the service.
193function setup() {
194 vi.clearAllMocks();
195 const stack = new AsyncDisposableStack();
196 registry = AtomRegistry.make();
197 const testRegistry = registry;
198 stack.defer(() => testRegistry.dispose());
199 state.mode = "notifications";
200 state.inApp = false;
201 state.settingsListeners.clear();
202 registry.set(environmentIdsAtom, ["one", "two"]);
203 state.shells.set("one", shell());
204 state.shells.set("two", shell());
205 focused = false;
206 visibility = "visible";
207 TestNotification.permission = "granted";
208 TestNotification.sent = [];
209 stack.use(stubGlobal("Notification", TestNotification));
210 stack.use(vi.spyOn(window, "focus").mockImplementation(() => undefined));
211 stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => focused));
212 stack.use(
213 vi
214 .spyOn(document, "visibilityState", "get")
215 .mockImplementation(() => visibility as DocumentVisibilityState),
216 );
217 const routeListeners = new Set<() => void>();
218 const route = { params: {} as Record<string, string> };
219 const router = {
220 state: { matches: [route] },
221 navigate: state.navigate,
222 subscribe: (_event: string, listener: () => void) => {
223 routeListeners.add(listener);
224 return () => routeListeners.delete(listener);
225 },
226 } as unknown as AnyRouter;
227 return {
228 start: () => {
229 stack.defer(startThreadNotifications(testRegistry, router));
230 },
231 setEnvironmentIds: (ids: ReadonlyArray<string>) => testRegistry.set(environmentIdsAtom, ids),
232 openThread: (environmentId: string, threadId: string) => {
233 route.params = { environmentId, threadId };
234 for (const listener of routeListeners) listener();
235 },
236 changeSettings: () => {
237 for (const listener of state.settingsListeners) listener();
238 },
239 [Symbol.asyncDispose]: () => stack.disposeAsync(),
240 };
241}
242
243it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
244 "badges %s and clears when notifications are disabled",
245 async (flag) => {
246 await using app = setup();
247 app.start();
248 state.shells.set("one", shell({ [flag]: true }));
249 expect(state.badge).toHaveBeenLastCalledWith(1);
250 const notification = TestNotification.sent[0]!;
251 notification.dispatchEvent(new Event("click"));
252 expect(state.navigate).toHaveBeenCalledWith({
253 to: "/$environmentId/$threadId",
254 params: { environmentId: EnvironmentId.make("one"), threadId: "thread" },
255 });
256 state.mode = "sound";
257 app.changeSettings();
258 expect(state.badge).toHaveBeenLastCalledWith(0);
259 expect(notification.close).toHaveBeenCalled();
260 },
261);
262
263it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
264 "notifies once per %s request, not again as queued runs start behind it",
265 async (flag) => {
266 await using app = setup();
267 app.start();
268 const waiting = (turnId: string, pendingRequestId: string) =>
269 shell({
270 [flag]: true,
271 pendingRequestId,
272 latestTurn: { turnId, state: "running", completedAt: null },
273 });
274 state.shells.set("one", waiting("turn-1", "request-1"));
275 state.shells.set("one", waiting("turn-2", "request-1"));
276 state.shells.set("one", waiting("turn-2", "request-2"));
277 const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed";
278 expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]);
279 },
280);
281
282it("presents completion notifications and sound as soon as the shell completes", async () => {
283 await using app = setup();
284 state.mode = "notifications-and-sound";
285 app.start();
286 state.shells.set(
287 "one",
288 shell({
289 latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:00:01.000Z" },
290 }),
291 );
292 expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]);
293 expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function));
294});
295
296it.each([false, true])(
297 "presents one completion when a monitor ends with a final wake (disconnected: %s)",
298 async (disconnect) => {
299 await using app = setup();
300 using _timers = fakeTimers();
301 state.mode = "notifications-and-sound";
302 app.start();
303 const watching = (
304 turnId: string,
305 turnState: string,
306 completedAt: string | null,
307 latestRunBackgroundWake: boolean,
308 pendingBackgroundTasks: ReadonlyArray<{ taskId: string; kind: "monitor" }>,
309 ) => ({
310 status: "live",
311 snapshot: Option.some({
312 threads: [
313 {
314 ...toV2ThreadShell({
315 ...thread,
316 latestTurn: { turnId, state: turnState, completedAt },
317 }),
318 latestRunBackgroundWake,
319 pendingBackgroundTasks,
320 },
321 ],
322 }),
323 });
324 const monitor = [{ taskId: "watch", kind: "monitor" as const }];
325 const titles = () => TestNotification.sent.map(({ title }) => title);
326 state.shells.set(
327 "one",
328 watching("turn", "completed", "2026-09-13T07:00:01.000Z", false, monitor),
329 );
330 expect(titles()).toEqual(["Thread completed"]);
331
332 state.shells.set("one", watching("wake", "running", null, true, monitor));
333 state.shells.set(
334 "one",
335 watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, monitor),
336 );
337 expect(titles()).toEqual(["Thread completed"]);
338
339 // The adapter drains the roster before the final wake starts.
340 state.shells.set("one", watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, []));
341 expect(titles()).toEqual(["Thread completed"]);
342 if (disconnect) {
343 state.shells.set("one", {
344 ...watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, []),
345 status: "cached",
346 });
347 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
348 expect(titles()).toEqual(["Thread completed"]);
349 }
350
351 state.shells.set("one", watching("final", "running", null, false, []));
352 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
353 state.shells.set("one", watching("final", "completed", "2026-09-13T07:00:11.000Z", false, []));
354 expect(titles()).toEqual(["Thread completed", "Thread completed"]);
355 expect(state.sound).toHaveBeenCalledTimes(2);
356 },
357);
358
359it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
360 "notifies for offline completion and %s once across reconnects",
361 async (flag) => {
362 await using app = setup();
363 const asking = { id: "asking", hasPendingApprovals: true };
364 const offline = { id: "offline", [flag]: true };
365 const done = {
366 id: "done",
367 latestTurn: {
368 turnId: "done-turn",
369 state: "completed",
370 completedAt: "2026-09-13T07:00:00.000Z",
371 },
372 };
373 state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done));
374 app.start();
375 state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done));
376 state.shells.set("one", {
377 ...shell({ id: "working" }, asking, { id: "offline" }, done),
378 status: "cached",
379 });
380
381 const completed = {
382 id: "working",
383 latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" },
384 };
385 state.shells.set("one", {
386 ...shell(completed, asking, offline, done),
387 status: "synchronizing",
388 });
389 state.shells.set("one", shell(completed, asking, offline, done));
390
391 expect(TestNotification.sent.map(({ title }) => title)).toEqual([
392 "Approval needed",
393 "Thread completed",
394 flag === "hasPendingApprovals" ? "Approval needed" : "Input needed",
395 ]);
396 state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" });
397 state.shells.set("one", shell(completed, asking, offline, done));
398 expect(TestNotification.sent.map(({ title }) => title)).toEqual([
399 "Approval needed",
400 "Thread completed",
401 flag === "hasPendingApprovals" ? "Approval needed" : "Input needed",
402 ]);
403 },
404);
405
406it.each([false, true])(
407 "does not replay a notified completion after reconnect (continued: %s)",
408 async (continued) => {
409 await using app = setup();
410 app.start();
411 const completed = shell({
412 latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" },
413 });
414 state.shells.set("one", completed);
415 state.shells.set("one", { ...completed, status: "cached" });
416 expect(TestNotification.sent.map(({ title }) => title)).toEqual(["Thread completed"]);
417 state.shells.set("one", continued ? shell() : completed);
418 expect(TestNotification.sent.map(({ title }) => title)).toEqual(["Thread completed"]);
419 },
420);
421
422it("opening a thread closes only its in-app toasts, and disabling notifications closes the rest", async () => {
423 await using app = setup();
424 state.inApp = true;
425 focused = true;
426 state.toast
427 .mockReturnValueOnce("one-completed")
428 .mockReturnValueOnce("one-approval")
429 .mockReturnValueOnce("one-other")
430 .mockReturnValueOnce("two-completed");
431 state.shells.set("one", shell({}, { id: "other" }));
432 app.start();
433 const completed = {
434 latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:01:00Z" },
435 };
436 state.shells.set("one", shell(completed, { id: "other" }));
437 state.shells.set("one", shell({ ...completed, hasPendingApprovals: true }, { id: "other" }));
438 state.shells.set(
439 "one",
440 shell({ ...completed, hasPendingApprovals: true }, { ...completed, id: "other" }),
441 );
442 state.shells.set("two", shell(completed));
443 expect(state.toast).toHaveBeenCalledTimes(4);
444
445 app.openThread("one", "thread");
446 expect(state.closeToast.mock.calls).toEqual([["one-completed"], ["one-approval"]]);
447
448 state.mode = "off";
449 state.inApp = false;
450 app.changeSettings();
451 expect(state.closeToast.mock.calls.slice(2).flat().toSorted()).toEqual([
452 "one-other",
453 "two-completed",
454 ]);
455});