1import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const harness = vi.hoisted(() => ({
5 cleanup: vi.fn(),
6 clearDraft: vi.fn<typeof import("./use-composer-drafts").clearComposerDraft>(),
7 flushDrafts: vi.fn<typeof import("./use-composer-drafts").flushComposerDrafts>(async () => {}),
8 waitForDrafts: vi.fn<typeof import("./use-composer-drafts").waitForComposerDraftsLoaded>(
9 async () => {},
10 ),
11 manager: null as unknown as ReturnType<
12 typeof import("./thread-outbox-manager").createThreadOutboxManager
13 >,
14}));
15
16vi.mock("./thread-outbox", async () => {
17 const { createThreadOutboxManager } = await import("./thread-outbox-manager");
18 const { appAtomRegistry } = await import("./atom-registry");
19 harness.manager = createThreadOutboxManager({
20 registry: appAtomRegistry,
21 storage: {
22 load: async () => ({ messages: [], errors: [] }),
23 write: async () => undefined,
24 remove: async () => undefined,
25 },
26 });
27 return { threadOutboxManager: harness.manager };
28});
29
30vi.mock("./use-composer-drafts", async (importOriginal) => {
31 const original = await importOriginal<typeof import("./use-composer-drafts")>();
32 return {
33 ...original,
34 clearComposerDraft: harness.clearDraft,
35 flushComposerDrafts: harness.flushDrafts,
36 scheduleUnusedComposerAttachmentCleanup: harness.cleanup,
37 waitForComposerDraftsLoaded: harness.waitForDrafts,
38 };
39});
40
41import { appAtomRegistry } from "./atom-registry";
42import { clearThreadOutboxEnvironment, removeThreadOutboxMessage } from "./thread-outbox-removal";
43import type { QueuedThreadMessage } from "./thread-outbox-model";
44import { composerDraftsAtom } from "./use-composer-drafts";
45
46function queuedMessage(input: {
47 readonly environmentId: string;
48 readonly messageId: string;
49 readonly fileUri: string;
50 readonly creation?: true;
51}): QueuedThreadMessage {
52 return {
53 environmentId: EnvironmentId.make(input.environmentId),
54 threadId: ThreadId.make(`thread-${input.messageId}`),
55 messageId: MessageId.make(input.messageId),
56 commandId: CommandId.make(`command-${input.messageId}`),
57 text: "Review the report",
58 attachments: [
59 {
60 id: `file-${input.messageId}`,
61 type: "file",
62 name: "report.pdf",
63 mimeType: "application/pdf",
64 sizeBytes: 42,
65 fileUri: input.fileUri,
66 },
67 ],
68 ...(input.creation
69 ? {
70 creation: {
71 projectId: ProjectId.make(`project-${input.messageId}`),
72 workspaceMode: "local" as const,
73 branch: null,
74 worktreePath: null,
75 },
76 }
77 : {}),
78 createdAt: "2026-08-24T12:00:00.000Z",
79 };
80}
81
82afterEach(() => {
83 appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {});
84 appAtomRegistry.set(composerDraftsAtom, {});
85 harness.cleanup.mockClear();
86 harness.clearDraft.mockClear();
87 harness.flushDrafts.mockReset();
88 harness.flushDrafts.mockResolvedValue(undefined);
89 harness.waitForDrafts.mockReset();
90 harness.waitForDrafts.mockResolvedValue(undefined);
91});
92
93describe("thread outbox removal", () => {
94 it("releases a removed message's attachment files with the removal itself", async () => {
95 const message = queuedMessage({
96 environmentId: "environment-1",
97 messageId: "message-1",
98 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
99 });
100 await harness.manager.enqueue(message);
101
102 await removeThreadOutboxMessage(message);
103
104 expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({});
105 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments);
106 });
107
108 it("keeps an edited message and its files when a revision-checked removal loses", async () => {
109 const message = queuedMessage({
110 environmentId: "environment-1",
111 messageId: "message-edited",
112 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
113 creation: true,
114 });
115 await harness.manager.enqueue(message);
116 const revision = harness.manager.revisionOf(message.messageId);
117 const edited = { ...message, text: "edited while restoring" };
118 await harness.manager.update(edited);
119
120 await expect(removeThreadOutboxMessage(message, revision)).resolves.toBe(false);
121
122 expect(harness.cleanup).not.toHaveBeenCalled();
123 expect(harness.waitForDrafts).not.toHaveBeenCalled();
124 expect(harness.clearDraft).not.toHaveBeenCalled();
125 expect(harness.flushDrafts).not.toHaveBeenCalled();
126 const remaining = Object.values(
127 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
128 ).flat();
129 expect(remaining).toEqual([edited]);
130 });
131
132 it("clears a removed pending task draft and includes its editor-only files", async () => {
133 const message = queuedMessage({
134 environmentId: "environment-1",
135 messageId: "message-pending",
136 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
137 creation: true,
138 });
139 const editorOnlyFile = {
140 id: "file-editor-only",
141 type: "file" as const,
142 name: "editor-only.pdf",
143 mimeType: "application/pdf",
144 sizeBytes: 84,
145 fileUri: "file:///documents/t3-composer-attachments/editor-only.pdf",
146 };
147 const draftKey = `pending-task:${message.messageId}`;
148 appAtomRegistry.set(composerDraftsAtom, {
149 [draftKey]: { text: "edited", attachments: [editorOnlyFile] },
150 });
151 await harness.manager.enqueue(message);
152
153 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
154
155 expect(harness.waitForDrafts).toHaveBeenCalledOnce();
156 expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(draftKey, {
157 deferAttachmentCleanup: true,
158 });
159 expect(harness.flushDrafts).toHaveBeenCalledOnce();
160 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([
161 ...message.attachments,
162 editorOnlyFile,
163 ]);
164 expect(harness.flushDrafts.mock.invocationCallOrder[0]).toBeLessThan(
165 harness.cleanup.mock.invocationCallOrder[0]!,
166 );
167 });
168
169 it("does not flush composer drafts when a removed creation has no editor draft", async () => {
170 const message = queuedMessage({
171 environmentId: "environment-1",
172 messageId: "message-without-editor-draft",
173 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
174 creation: true,
175 });
176 await harness.manager.enqueue(message);
177
178 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
179
180 expect(harness.waitForDrafts).toHaveBeenCalledOnce();
181 expect(harness.clearDraft).not.toHaveBeenCalled();
182 expect(harness.flushDrafts).not.toHaveBeenCalled();
183 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments);
184 });
185
186 it("releases only the cleared environment's queued attachment files", async () => {
187 const cleared = queuedMessage({
188 environmentId: "environment-1",
189 messageId: "message-cleared",
190 fileUri: "file:///documents/t3-composer-attachments/cleared.pdf",
191 });
192 const kept = queuedMessage({
193 environmentId: "environment-2",
194 messageId: "message-kept",
195 fileUri: "file:///documents/t3-composer-attachments/kept.pdf",
196 });
197 await harness.manager.enqueue(cleared);
198 await harness.manager.enqueue(kept);
199
200 await clearThreadOutboxEnvironment(cleared.environmentId);
201
202 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(cleared.attachments);
203 const remaining = Object.values(
204 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
205 ).flat();
206 expect(remaining.map((message) => message.messageId)).toEqual([kept.messageId]);
207 });
208
209 it("clears only removed pending drafts and keeps drafts for live messages", async () => {
210 const cleared = queuedMessage({
211 environmentId: "environment-1",
212 messageId: "message-cleared-pending",
213 fileUri: "file:///documents/t3-composer-attachments/cleared-pending.pdf",
214 creation: true,
215 });
216 const replaced = queuedMessage({
217 environmentId: "environment-1",
218 messageId: "message-replaced-pending",
219 fileUri: "file:///documents/t3-composer-attachments/replaced-pending.pdf",
220 creation: true,
221 });
222 const kept = queuedMessage({
223 environmentId: "environment-2",
224 messageId: "message-kept-pending",
225 fileUri: "file:///documents/t3-composer-attachments/kept-pending.pdf",
226 creation: true,
227 });
228 const replacement = { ...replaced, text: "replacement queued while drafts hydrate" };
229 const editorOnlyFile = {
230 id: "file-cleared-editor",
231 type: "file" as const,
232 name: "cleared-editor.pdf",
233 mimeType: "application/pdf",
234 sizeBytes: 84,
235 fileUri: "file:///documents/t3-composer-attachments/cleared-editor.pdf",
236 };
237 const hydrationStarted = Promise.withResolvers<void>();
238 const hydrationBarrier = Promise.withResolvers<void>();
239 harness.waitForDrafts.mockImplementationOnce(async () => {
240 hydrationStarted.resolve();
241 await hydrationBarrier.promise;
242 });
243 const clearedDraftKey = `pending-task:${cleared.messageId}`;
244 appAtomRegistry.set(composerDraftsAtom, {
245 [clearedDraftKey]: { text: "edited", attachments: [editorOnlyFile] },
246 [`pending-task:${replaced.messageId}`]: { text: "replacement", attachments: [] },
247 [`pending-task:${kept.messageId}`]: { text: "other environment", attachments: [] },
248 });
249 await Promise.all([
250 harness.manager.enqueue(cleared),
251 harness.manager.enqueue(replaced),
252 harness.manager.enqueue(kept),
253 ]);
254
255 const clearing = clearThreadOutboxEnvironment(cleared.environmentId);
256 await hydrationStarted.promise;
257 const replacing = harness.manager.enqueue(replacement);
258 hydrationBarrier.resolve();
259 await clearing;
260 await replacing;
261
262 expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(clearedDraftKey, {
263 deferAttachmentCleanup: true,
264 });
265 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([
266 ...cleared.attachments,
267 ...replaced.attachments,
268 editorOnlyFile,
269 ]);
270 const remaining = Object.values(
271 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
272 ).flat();
273 expect(remaining).toEqual(expect.arrayContaining([replacement, kept]));
274 });
275
276 it("keeps removal successful when pending draft persistence fails", async () => {
277 const message = queuedMessage({
278 environmentId: "environment-1",
279 messageId: "message-draft-flush-fails",
280 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
281 creation: true,
282 });
283 const flushError = new Error("composer storage unavailable");
284 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
285 harness.flushDrafts.mockRejectedValueOnce(flushError);
286 appAtomRegistry.set(composerDraftsAtom, {
287 [`pending-task:${message.messageId}`]: { text: "edited", attachments: [] },
288 });
289 await harness.manager.enqueue(message);
290
291 try {
292 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
293
294 expect(harness.clearDraft).toHaveBeenCalledOnce();
295 expect(harness.cleanup).not.toHaveBeenCalled();
296 expect(warning).toHaveBeenCalledWith(
297 "[thread-outbox] failed to clean up removed pending task drafts",
298 flushError,
299 );
300 expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({});
301 } finally {
302 warning.mockRestore();
303 }
304 });
305});