1import { RunId } from "@t3tools/contracts";
2import { Atom, AtomRegistry } from "effect/reactivity";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 makeQueuedRunEditStore,
7 makeQueuedRunsAtom,
8 queuedRunEditBlockedReason,
9 resolveQueuedEditExit,
10 type QueuedRunStatus,
11} from "./queuedRunEdit.ts";
12
13const RUN = RunId.make("run-1");
14const queued = (status = "queued"): ReadonlyArray<QueuedRunStatus> => [{ id: RUN, status }];
15
16function setup(initialRuns: ReadonlyArray<QueuedRunStatus> | null = queued()) {
17 const registry = AtomRegistry.make();
18 const runs = Atom.make<ReadonlyArray<QueuedRunStatus> | null>(initialRuns);
19 const store = makeQueuedRunEditStore<{ readonly runId: RunId; readonly text: string }>({
20 registry,
21 label: "test-queued-run-edits",
22 });
23 const exits: Array<{ threadKey: string; text: string }> = [];
24 const begin = () =>
25 store.begin(
26 "env:thread",
27 { runId: RUN, text: "draft" },
28 { runs, onRunLeftQueue: (threadKey, edit) => exits.push({ threadKey, text: edit.text }) },
29 );
30 return { registry, runs, store, exits, begin };
31}
32
33describe("resolveQueuedEditExit", () => {
34 it("closes an unchanged edit without touching drafts", () => {
35 expect(resolveQueuedEditExit({ editChanged: false, threadDraftEmpty: true })).toBe("clean");
36 });
37
38 it("keeps a changed edit only when the thread draft is empty", () => {
39 expect(resolveQueuedEditExit({ editChanged: true, threadDraftEmpty: true })).toBe("kept");
40 expect(resolveQueuedEditExit({ editChanged: true, threadDraftEmpty: false })).toBe("discarded");
41 });
42});
43
44describe("queuedRunEditBlockedReason", () => {
45 it("blocks edits only while a question holds the composer", () => {
46 expect(queuedRunEditBlockedReason({ openQuestionCount: 0 })).toBeNull();
47 expect(queuedRunEditBlockedReason({ openQuestionCount: 1 })).toBe(
48 "Answer the question to edit queued messages",
49 );
50 });
51});
52
53describe("makeQueuedRunEditStore", () => {
54 it("fires once when the edited run leaves the queue, with the latest edit", () => {
55 const { registry, runs, store, exits, begin } = setup();
56 begin();
57 store.update("env:thread", (edit) => ({ ...edit, text: "changed" }));
58 registry.set(runs, queued());
59 expect(exits).toEqual([]);
60 registry.set(runs, queued("running"));
61 registry.set(runs, []);
62 expect(exits).toEqual([{ threadKey: "env:thread", text: "changed" }]);
63 });
64
65 it("waits while the runs are unknown and treats a vanished run as left", () => {
66 const { registry, runs, exits, begin } = setup(null);
67 begin();
68 expect(exits).toEqual([]);
69 registry.set(runs, []);
70 expect(exits).toHaveLength(1);
71 });
72
73 it("fires immediately when the run already left the queue", () => {
74 const { exits, begin } = setup(queued("running"));
75 begin();
76 expect(exits).toHaveLength(1);
77 });
78
79 it("stops watching when the edit ends", () => {
80 const { registry, runs, store, exits, begin } = setup();
81 begin();
82 store.end("env:thread");
83 registry.set(runs, []);
84 expect(exits).toEqual([]);
85 expect(store.get("env:thread")).toBeNull();
86 });
87
88 it("holds the exit during a save and replays it when the save fails", () => {
89 const { registry, runs, store, exits, begin } = setup();
90 begin();
91 const finishSave = store.startSave("env:thread");
92 registry.set(runs, []);
93 expect(exits).toEqual([]);
94 expect(finishSave?.(false)).toBe(false);
95 expect(exits).toHaveLength(1);
96 });
97
98 it("reports the edit still open when a save fails while its run is queued", () => {
99 const { store, exits, begin } = setup();
100 begin();
101 const finishSave = store.startSave("env:thread");
102 expect(finishSave?.(false)).toBe(true);
103 expect(exits).toEqual([]);
104 expect(store.get("env:thread")).toEqual({ runId: RUN, text: "draft" });
105 });
106
107 it("allows one save at a time and ends the edit when it succeeds", () => {
108 const { registry, store, begin } = setup();
109 begin();
110 const finishSave = store.startSave("env:thread");
111 expect(registry.get(store.savingAtom)).toEqual({ "env:thread": true });
112 expect(store.startSave("env:thread")).toBeNull();
113 expect(finishSave?.(true)).toBe(true);
114 expect(store.get("env:thread")).toBeNull();
115 expect(registry.get(store.savingAtom)).toEqual({});
116 });
117
118 it("leaves an edit begun again during a save to that edit", () => {
119 const { registry, runs, store, exits, begin } = setup();
120 begin();
121 const finishStale = store.startSave("env:thread");
122 begin();
123 store.update("env:thread", (edit) => ({ ...edit, text: "newer" }));
124
125 expect(finishStale?.(true)).toBe(false);
126 expect(finishStale?.(false)).toBe(false);
127 expect(store.get("env:thread")).toEqual({ runId: RUN, text: "newer" });
128 registry.set(runs, []);
129 expect(exits).toEqual([{ threadKey: "env:thread", text: "newer" }]);
130 });
131
132 // The server orders saves by arrival, not by edit age, so a second save could
133 // land before the first and then be overwritten by it.
134 it("keeps the save claimed until it settles, even when the edit is begun again or ended", () => {
135 const { registry, store, begin } = setup();
136 begin();
137 const finishStale = store.startSave("env:thread");
138 begin();
139 expect(registry.get(store.savingAtom)).toEqual({ "env:thread": true });
140 expect(store.startSave("env:thread")).toBeNull();
141 store.end("env:thread");
142 begin();
143 expect(store.startSave("env:thread")).toBeNull();
144
145 expect(finishStale?.(true)).toBe(false);
146 expect(registry.get(store.savingAtom)).toEqual({});
147 expect(finishStale?.(false)).toBe(false);
148 const finishNewer = store.startSave("env:thread");
149 expect(finishNewer?.(true)).toBe(true);
150 expect(store.get("env:thread")).toBeNull();
151 });
152
153 it("does not revive a cancelled edit when its save fails", () => {
154 const { registry, runs, store, exits, begin } = setup();
155 begin();
156 const finishSave = store.startSave("env:thread");
157 store.end("env:thread");
158 expect(finishSave?.(false)).toBe(false);
159 registry.set(runs, []);
160 expect(exits).toEqual([]);
161 expect(registry.get(store.savingAtom)).toEqual({});
162 });
163
164 // The runs read the thread's detail, a live server subscription; a thread
165 // with no edit open must not keep it.
166 it("holds the thread's runs only while an edit is open and watching", () => {
167 // The registry removes idle nodes in a scheduled task; the test runs those tasks itself.
168 const tasks: Array<() => void> = [];
169 const registry = AtomRegistry.make({
170 scheduleTask: (task) => {
171 tasks.push(task);
172 return () => {};
173 },
174 });
175 const settle = () => {
176 for (const task of tasks.splice(0)) task();
177 };
178 const source = Atom.make<ReadonlyArray<QueuedRunStatus> | null>(queued());
179 let mounted = 0;
180 const runs = Atom.make((get) => {
181 mounted += 1;
182 get.addFinalizer(() => {
183 mounted -= 1;
184 });
185 return get(source);
186 });
187 const store = makeQueuedRunEditStore<{ readonly runId: RunId }>({
188 registry,
189 label: "test-queued-run-edits",
190 });
191 const begin = () =>
192 store.begin("env:thread", { runId: RUN }, { runs, onRunLeftQueue: () => {} });
193
194 begin();
195 expect(mounted).toBe(1);
196 store.end("env:thread");
197 settle();
198 expect(mounted).toBe(0);
199
200 begin();
201 const finishSave = store.startSave("env:thread");
202 settle();
203 expect(mounted).toBe(0);
204 finishSave?.(false);
205 expect(mounted).toBe(1);
206
207 registry.set(source, []);
208 expect(store.get("env:thread")).toBeNull();
209 settle();
210 expect(mounted).toBe(0);
211 });
212});
213
214describe("makeQueuedRunsAtom", () => {
215 const thread = (overrides: { archivedAt?: string | null; deletedAt?: string | null } = {}) => ({
216 id: "thread-1",
217 archivedAt: null,
218 deletedAt: null,
219 ...overrides,
220 });
221
222 function setupWatched() {
223 const registry = AtomRegistry.make();
224 const snapshot = Atom.make<{ threads: ReadonlyArray<ReturnType<typeof thread>> } | null>({
225 threads: [thread()],
226 });
227 let detailReads = 0;
228 const detailStatus = Atom.make("live");
229 const detail = Atom.make<ReadonlyArray<QueuedRunStatus> | null>(queued());
230 const runs = makeQueuedRunsAtom({
231 threadId: "thread-1",
232 snapshot,
233 detailStatus,
234 runs: Atom.make((get) => {
235 detailReads += 1;
236 return get(detail);
237 }),
238 });
239 const store = makeQueuedRunEditStore<{ readonly runId: RunId }>({
240 registry,
241 label: "test-queued-run-edits",
242 });
243 const exits: Array<string> = [];
244 store.begin("env:thread", { runId: RUN }, { runs, onRunLeftQueue: (key) => exits.push(key) });
245 return {
246 registry,
247 snapshot,
248 detailStatus,
249 detail,
250 store,
251 exits,
252 detailReads: () => detailReads,
253 };
254 }
255
256 it("ends the edit when the shell marks the thread deleted, without reading its detail again", () => {
257 const { registry, snapshot, detail, exits, detailReads } = setupWatched();
258 expect(exits).toEqual([]);
259 registry.set(snapshot, { threads: [thread({ deletedAt: "2026-10-24T00:00:00Z" })] });
260 expect(exits).toEqual(["env:thread"]);
261 const reads = detailReads();
262 registry.set(detail, []);
263 expect(detailReads()).toBe(reads);
264 });
265
266 it("ends the edit when the detail reports the thread gone", () => {
267 const { registry, snapshot, detailStatus, exits } = setupWatched();
268 registry.set(snapshot, { threads: [] });
269 expect(exits).toEqual([]);
270 registry.set(detailStatus, "deleted");
271 expect(exits).toEqual(["env:thread"]);
272 });
273
274 it("keeps an archived thread's edit open, ending it only when the run leaves the queue", () => {
275 const { registry, snapshot, detail, store, exits } = setupWatched();
276 // The shell drops archived threads, but their queue stays editable.
277 registry.set(snapshot, { threads: [] });
278 registry.set(snapshot, { threads: [thread({ archivedAt: "2026-10-24T00:00:00Z" })] });
279 expect(exits).toEqual([]);
280 expect(store.get("env:thread")).not.toBeNull();
281 registry.set(detail, queued("running"));
282 expect(exits).toEqual(["env:thread"]);
283 });
284
285 it("waits while the thread detail is unknown", () => {
286 const { registry, snapshot, detail, exits } = setupWatched();
287 registry.set(snapshot, null);
288 registry.set(detail, null);
289 expect(exits).toEqual([]);
290 registry.set(detail, []);
291 expect(exits).toEqual(["env:thread"]);
292 });
293});