1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createThreadNotificationGate,
5 RELEASED_COMPLETION_GRACE_MS,
6 type ThreadNotificationObservation,
7} from "./ThreadNotificationCoordinator.logic";
8
9const working: ThreadNotificationObservation = {
10 attention: null,
11 requestId: null,
12 settled: false,
13 completedAt: null,
14 backgroundWake: false,
15};
16const completed = (at: number): ThreadNotificationObservation => ({
17 attention: null,
18 requestId: null,
19 settled: true,
20 completedAt: at,
21 backgroundWake: false,
22});
23const waking: ThreadNotificationObservation = { ...working, backgroundWake: true };
24const woke = (at: number): ThreadNotificationObservation => ({
25 ...completed(at),
26 backgroundWake: true,
27});
28const visible = { archived: false, quiet: false };
29
30function fakeTimers(): Disposable {
31 vi.useFakeTimers();
32 return { [Symbol.dispose]: () => void vi.useRealTimers() };
33}
34
35function harness() {
36 const notify = vi.fn();
37 const gate = createThreadNotificationGate<string>(notify);
38 let prior: ThreadNotificationObservation | undefined;
39 const observe = (next: ThreadNotificationObservation, options = visible) => {
40 gate.observe("thread", prior, next, options);
41 prior = next;
42 };
43 return { gate, notify, observe };
44}
45
46describe("createThreadNotificationGate", () => {
47 it("notifies a completion immediately and only once", () => {
48 const { notify, observe } = harness();
49 observe(working);
50 observe(completed(1));
51 expect(notify.mock.calls).toEqual([["thread", "completion"]]);
52 observe({ ...completed(1) });
53 observe(working);
54 observe(completed(1));
55 expect(notify).toHaveBeenCalledTimes(1);
56 });
57
58 it("notifies each newer completion immediately", () => {
59 const { notify, observe } = harness();
60 observe(working);
61 observe(completed(1));
62 observe(working);
63 observe(completed(2));
64 expect(notify.mock.calls).toEqual([
65 ["thread", "completion"],
66 ["thread", "completion"],
67 ]);
68 observe(completed(1));
69 expect(notify).toHaveBeenCalledTimes(2);
70 });
71
72 it("notifies each attention once per stretch and still notifies the subsequent completion", () => {
73 const { notify, observe } = harness();
74 observe(working);
75 const attention = { ...working, attention: "run-1:approval:a", requestId: "a" };
76 observe(attention);
77 expect(notify.mock.calls).toEqual([["thread", "input"]]);
78 observe({ ...attention });
79 expect(notify).toHaveBeenCalledTimes(1);
80 observe({ ...attention, attention: "run-1:approval:b", requestId: "b" });
81 observe(attention);
82 expect(notify).toHaveBeenCalledTimes(2);
83 observe(working);
84 observe(attention);
85 expect(notify).toHaveBeenCalledTimes(3);
86 const subagent = { ...attention, attention: "run-1:approval:", requestId: null };
87 observe(subagent);
88 observe(attention);
89 observe(subagent);
90 expect(notify).toHaveBeenCalledTimes(5);
91 observe(working);
92 observe(completed(1));
93 expect(notify.mock.calls).toEqual([
94 ["thread", "input"],
95 ["thread", "input"],
96 ["thread", "input"],
97 ["thread", "input"],
98 ["thread", "input"],
99 ["thread", "completion"],
100 ]);
101 });
102
103 it("does not replay a completion after attention resolves", () => {
104 const { notify, observe } = harness();
105 observe(working);
106 observe(completed(1));
107 observe({ ...working, attention: "run-1:limited" });
108 observe(completed(1));
109 expect(notify.mock.calls).toEqual([
110 ["thread", "completion"],
111 ["thread", "input"],
112 ]);
113 });
114
115 it("never notifies existing, archived or stopped completions, including when unarchived", () => {
116 const { notify, observe } = harness();
117 observe(completed(1));
118 observe(working);
119 observe(completed(2), { ...visible, quiet: true });
120 observe(completed(2));
121 observe(working);
122 observe(completed(3), { ...visible, archived: true });
123 observe(completed(3));
124 expect(notify).not.toHaveBeenCalled();
125 observe(working);
126 observe(completed(4));
127 expect(notify.mock.calls).toEqual([["thread", "completion"]]);
128 });
129
130 it("forgets handled completions when a thread is removed or the gate is cleared", () => {
131 for (const reset of [
132 (gate: ReturnType<typeof createThreadNotificationGate<string>>) => gate.retain(new Set()),
133 (gate: ReturnType<typeof createThreadNotificationGate<string>>) => gate.clear(),
134 ]) {
135 const { gate, notify, observe } = harness();
136 observe(completed(1));
137 reset(gate);
138 gate.observe("thread", undefined, working, visible);
139 gate.observe("thread", working, completed(1), visible);
140 expect(notify.mock.calls).toEqual([["thread", "completion"]]);
141 }
142 });
143
144 it("waits out the grace before notifying a wake's completion, and a final wake replaces it", () => {
145 using _timers = fakeTimers();
146 const { notify, observe } = harness();
147 observe(working);
148 observe(completed(1));
149 // Held until the monitor left, or completing after it already had: either way unheld here.
150 observe(waking);
151 observe(woke(2));
152 expect(notify).toHaveBeenCalledTimes(1);
153 observe(working);
154 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
155 expect(notify).toHaveBeenCalledTimes(1);
156 observe(completed(3));
157 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
158 expect(notify.mock.calls).toEqual([
159 ["thread", "completion"],
160 ["thread", "completion"],
161 ]);
162 });
163
164 it("notifies a wake's completion once after the grace when no run follows, even across a pause", () => {
165 using _timers = fakeTimers();
166 const { gate, notify, observe } = harness();
167 observe(waking);
168 observe(woke(1));
169 gate.pause();
170 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
171 expect(notify).not.toHaveBeenCalled();
172 observe({ ...woke(1) });
173 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS - 1);
174 observe({ ...woke(1) });
175 expect(notify).not.toHaveBeenCalled();
176 vi.advanceTimersByTime(1);
177 expect(notify.mock.calls).toEqual([["thread", "completion"]]);
178 observe({ ...woke(1) });
179 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
180 expect(notify).toHaveBeenCalledTimes(1);
181 });
182
183 it("drops a pending wake completion when the thread is removed or the gate is cleared", () => {
184 using _timers = fakeTimers();
185 for (const reset of [
186 (gate: ReturnType<typeof createThreadNotificationGate<string>>) => gate.retain(new Set()),
187 (gate: ReturnType<typeof createThreadNotificationGate<string>>) => gate.clear(),
188 ]) {
189 const { gate, notify, observe } = harness();
190 observe(waking);
191 observe(woke(1));
192 reset(gate);
193 vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
194 expect(notify).not.toHaveBeenCalled();
195 }
196 });
197});