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("releases only the cleared environment's queued attachment files", async () => {
133 const cleared = queuedMessage({
134 environmentId: "environment-1",
135 messageId: "message-cleared",
136 fileUri: "file:///documents/t3-composer-attachments/cleared.pdf",
137 });
138 const kept = queuedMessage({
139 environmentId: "environment-2",
140 messageId: "message-kept",
141 fileUri: "file:///documents/t3-composer-attachments/kept.pdf",
142 });
143 await harness.manager.enqueue(cleared);
144 await harness.manager.enqueue(kept);
145
146 await clearThreadOutboxEnvironment(cleared.environmentId);
147
148 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(cleared.attachments);
149 const remaining = Object.values(
150 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
151 ).flat();
152 expect(remaining.map((message) => message.messageId)).toEqual([kept.messageId]);
153 });
154
155 it("clears only removed pending drafts and keeps drafts for live messages", async () => {
156 const cleared = queuedMessage({
157 environmentId: "environment-1",
158 messageId: "message-cleared-pending",
159 fileUri: "file:///documents/t3-composer-attachments/cleared-pending.pdf",
160 creation: true,
161 });
162 const replaced = queuedMessage({
163 environmentId: "environment-1",
164 messageId: "message-replaced-pending",
165 fileUri: "file:///documents/t3-composer-attachments/replaced-pending.pdf",
166 creation: true,
167 });
168 const kept = queuedMessage({
169 environmentId: "environment-2",
170 messageId: "message-kept-pending",
171 fileUri: "file:///documents/t3-composer-attachments/kept-pending.pdf",
172 creation: true,
173 });
174 const replacement = { ...replaced, text: "replacement queued while drafts hydrate" };
175 const editorOnlyFile = {
176 id: "file-cleared-editor",
177 type: "file" as const,
178 name: "cleared-editor.pdf",
179 mimeType: "application/pdf",
180 sizeBytes: 84,
181 fileUri: "file:///documents/t3-composer-attachments/cleared-editor.pdf",
182 };
183 const hydrationStarted = Promise.withResolvers<void>();
184 const hydrationBarrier = Promise.withResolvers<void>();
185 harness.waitForDrafts.mockImplementationOnce(async () => {
186 hydrationStarted.resolve();
187 await hydrationBarrier.promise;
188 });
189 const clearedDraftKey = `pending-task:${cleared.messageId}`;
190 appAtomRegistry.set(composerDraftsAtom, {
191 [clearedDraftKey]: { text: "edited", attachments: [editorOnlyFile] },
192 [`pending-task:${replaced.messageId}`]: { text: "replacement", attachments: [] },
193 [`pending-task:${kept.messageId}`]: { text: "other environment", attachments: [] },
194 });
195 await Promise.all([
196 harness.manager.enqueue(cleared),
197 harness.manager.enqueue(replaced),
198 harness.manager.enqueue(kept),
199 ]);
200
201 const clearing = clearThreadOutboxEnvironment(cleared.environmentId);
202 await hydrationStarted.promise;
203 const replacing = harness.manager.enqueue(replacement);
204 hydrationBarrier.resolve();
205 await clearing;
206 await replacing;
207
208 expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(clearedDraftKey, {
209 deferAttachmentCleanup: true,
210 });
211 expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([
212 ...cleared.attachments,
213 ...replaced.attachments,
214 editorOnlyFile,
215 ]);
216 const remaining = Object.values(
217 appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom),
218 ).flat();
219 expect(remaining).toEqual(expect.arrayContaining([replacement, kept]));
220 });
221
222 it("keeps removal successful when pending draft persistence fails", async () => {
223 const message = queuedMessage({
224 environmentId: "environment-1",
225 messageId: "message-draft-flush-fails",
226 fileUri: "file:///documents/t3-composer-attachments/queued.pdf",
227 creation: true,
228 });
229 const flushError = new Error("composer storage unavailable");
230 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
231 harness.flushDrafts.mockRejectedValueOnce(flushError);
232 appAtomRegistry.set(composerDraftsAtom, {
233 [`pending-task:${message.messageId}`]: { text: "edited", attachments: [] },
234 });
235 await harness.manager.enqueue(message);
236
237 try {
238 await expect(removeThreadOutboxMessage(message)).resolves.toBe(true);
239
240 expect(harness.clearDraft).toHaveBeenCalledOnce();
241 expect(harness.cleanup).not.toHaveBeenCalled();
242 expect(warning).toHaveBeenCalledWith(
243 "[thread-outbox] failed to clean up removed pending task drafts",
244 flushError,
245 );
246 expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({});
247 } finally {
248 warning.mockRestore();
249 }
250 });
251});