1import { afterEach, describe, expect, it } from "@effect/vitest";
2import {
3 CommandId,
4 ComposerContextId,
5 EnvironmentId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import { onTestFinished, vi } from "vite-plus/test";
12
13const composerDraftFileMocks = vi.hoisted(() => {
14 let document = JSON.stringify({ schemaVersion: 1, drafts: {} });
15 let readError: Error | null = null;
16 let writeError: Error | null = null;
17 let releaseRead: (() => void) | null = null;
18 let readBarrier = Promise.resolve();
19 let nextWriteBarrier: Promise<void> | null = null;
20 let onWrite: (() => void) | null = null;
21 const writes: string[] = [];
22 const readImage = vi.fn(async () => "YWJj");
23
24 return {
25 readImage,
26 blockRead() {
27 readBarrier = new Promise<void>((resolve) => {
28 releaseRead = resolve;
29 });
30 },
31 releaseRead() {
32 releaseRead?.();
33 releaseRead = null;
34 },
35 getDocument() {
36 return document;
37 },
38 setDocument(value: unknown) {
39 document = JSON.stringify(value);
40 },
41 setReadError(error: Error | null) {
42 readError = error;
43 },
44 setWriteError(error: Error | null) {
45 writeError = error;
46 },
47 setNextWriteBarrier(barrier: Promise<void> | null) {
48 nextWriteBarrier = barrier;
49 },
50 setOnWrite(callback: (() => void) | null) {
51 onWrite = callback;
52 },
53 getWrites(): ReadonlyArray<string> {
54 return writes;
55 },
56 resetWrites() {
57 writes.length = 0;
58 },
59 Directory: class {
60 create() {}
61
62 list() {
63 return [];
64 }
65 },
66 File: class {
67 exists = true;
68 parentDirectory = null;
69
70 create() {}
71
72 moveSync() {}
73
74 async text() {
75 await readBarrier;
76 if (readError) throw readError;
77 return document;
78 }
79
80 async base64() {
81 return readImage();
82 }
83
84 write(value: string) {
85 if (writeError) {
86 throw writeError;
87 }
88 if (nextWriteBarrier) {
89 const barrier = nextWriteBarrier;
90 nextWriteBarrier = null;
91 return barrier.then(() => {
92 document = value;
93 writes.push(value);
94 onWrite?.();
95 });
96 }
97 document = value;
98 writes.push(value);
99 onWrite?.();
100 }
101 },
102 };
103});
104
105const composerAttachmentCleanupMocks = vi.hoisted(() => ({
106 remove: vi.fn(async () => undefined),
107 releaseUploads: vi.fn(async () => undefined),
108}));
109
110const incomingShareStorageMocks = vi.hoisted(() => ({
111 load: vi.fn<typeof import("../features/sharing/incoming-share-storage").loadIncomingShareDrafts>(
112 async () => [],
113 ),
114}));
115
116vi.mock("expo-file-system", () => ({
117 Directory: composerDraftFileMocks.Directory,
118 File: composerDraftFileMocks.File,
119 Paths: { document: { uri: "file:///documents" } },
120}));
121
122vi.mock("../lib/composerImages", async (importOriginal) => ({
123 ...(await importOriginal<typeof import("../lib/composerImages")>()),
124 removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,
125}));
126
127vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));
128vi.mock("./assets", () => ({ assetEnvironment: {} }));
129vi.mock("./attachments", () => ({ attachmentEnvironment: {} }));
130vi.mock("./session", () => ({ environmentSession: {} }));
131vi.mock("@t3tools/client-runtime/state/runtime", () => ({
132 createEnvironmentRpcCommand: () => Symbol("rpc-command"),
133 executeAtomQuery: () => {
134 throw new Error("Unexpected network query in the inline read test");
135 },
136 runAtomCommand: () => {
137 throw new Error("Unexpected network command in the inline read test");
138 },
139 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
140}));
141
142vi.mock("../lib/attachmentUpload", () => ({
143 releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,
144}));
145
146vi.mock("../features/sharing/incoming-share-storage", () => ({
147 loadIncomingShareDrafts: incomingShareStorageMocks.load,
148}));
149
150import type { DraftComposerAttachment } from "../lib/composerImages";
151import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
152import { appAtomRegistry } from "./atom-registry";
153import { threadOutboxManager } from "./thread-outbox";
154import {
155 appendComposerDraftAttachments,
156 captureComposerDraftInsertion,
157 countComposerDraftAttachmentsAfterSelection,
158 getComposerDraftAfterSelection,
159 archiveCloudComposerDrafts,
160 clearComposerDraftContent,
161 clearComposerDraftContentState,
162 clearComposerDraftsEnvironment,
163 ComposerDraftPersistenceError,
164 composerDraftsAtom,
165 composerCloudDraftsAtom,
166 createNewTaskDraft,
167 createComposerDraftContextHistory,
168 setComposerDraftContext,
169 decodePersistedComposerState,
170 ensureComposerDraftsLoaded,
171 type ComposerDraft,
172 findNewTaskDraftKeys,
173 findLocalComposerClipboardAttachment,
174 flushComposerDrafts,
175 getComposerDraftSnapshot,
176 mergeComposerDraftContentState,
177 migrateLegacyNewTaskDraft,
178 modelOptionMemoryAtom,
179 releaseUnusedComposerAttachmentFiles,
180 removeComposerDraftsForEnvironment,
181 replaceComposerDraftAttachments,
182 resetComposerDraftsLoadState,
183 restoreComposerDraftSnapshotState,
184 restoreCloudComposerDrafts,
185 retargetNewTaskDraft,
186 setComposerDraftText,
187 insertComposerDraftContext,
188 insertComposerDraftText,
189 rememberComposerDraftSelection,
190 setComposerDraftAttachmentUpload,
191 waitForComposerDraftsLoaded,
192 setStickyComposerModelSelection,
193 stickyComposerModelSelectionAtom,
194 undoComposerDraftMerge,
195 undoComposerDraftMergeState,
196} from "./use-composer-drafts";
197import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention";
198
199const DRAFT: ComposerDraft = {
200 text: "hello",
201 attachments: [],
202};
203
204afterEach(() => {
205 vi.useRealTimers();
206 resetComposerDraftsLoadState();
207 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
208 composerDraftFileMocks.setReadError(null);
209 composerDraftFileMocks.setWriteError(null);
210 composerDraftFileMocks.setNextWriteBarrier(null);
211 composerDraftFileMocks.setOnWrite(null);
212 composerDraftFileMocks.resetWrites();
213 composerDraftFileMocks.readImage.mockReset();
214 composerDraftFileMocks.readImage.mockResolvedValue("YWJj");
215 appAtomRegistry.set(composerDraftsAtom, {});
216 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
217 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
218 appAtomRegistry.set(modelOptionMemoryAtom, {});
219 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
220 composerAttachmentCleanupMocks.remove.mockClear();
221 composerAttachmentCleanupMocks.releaseUploads.mockReset();
222 composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined);
223 incomingShareStorageMocks.load.mockReset();
224 incomingShareStorageMocks.load.mockResolvedValue([]);
225});
226
227function contextDraft(start: number, count: number): ComposerDraft {
228 const records = Array.from({ length: count }, (_, index) => ({
229 version: 1 as const,
230 contextId: ComposerContextId.make(`skill-${start + index}`),
231 kind: "skill" as const,
232 label: "Skill",
233 name: "skill",
234 }));
235 return {
236 text: records.map((record) => `[Skill](t3-context://v1/skill/${record.contextId})`).join(" "),
237 context: { version: 1, records },
238 attachments: [],
239 };
240}
241
242describe("mobile composer drafts", () => {
243 it("restores a PR checkout source after restarting the composer", () => {
244 const draft: ComposerDraft = {
245 text: "Address feedback",
246 attachments: [],
247 workspaceSelection: {
248 mode: "worktree",
249 branch: "fork/feature",
250 worktreePath: null,
251 startFromOrigin: false,
252 pullRequestReference: "https://github.com/acme/repo/pull/123",
253 },
254 };
255 expect(
256 decodePersistedComposerState({ schemaVersion: 1, drafts: { task: draft } }).drafts.task,
257 ).toEqual(draft);
258 });
259 it.each([false, true])(
260 "restores visible file chips from legacy drafts (archived: %s)",
261 async (archived) => {
262 const file = {
263 type: "file" as const,
264 id: "legacy-file",
265 name: "notes.txt",
266 mimeType: "text/plain",
267 sizeBytes: 10,
268 fileUri: "file:///notes.txt",
269 };
270 const image = { ...file, id: "photo", name: "photo.png", mimeType: "image/png" };
271 const video = { ...file, id: "video", name: "clip.mp4", mimeType: "video/mp4" };
272 const legacy = { text: "Review these", attachments: [file, image, video] };
273 const document = {
274 schemaVersion: 1,
275 drafts: archived ? {} : { thread: legacy },
276 ...(archived
277 ? { signedOutDrafts: { account: { drafts: { thread: legacy }, queuedMessages: [] } } }
278 : {}),
279 };
280 const decoded = decodePersistedComposerState(document);
281 const restored = archived
282 ? decoded.cloudDrafts.signedOut.account?.drafts.thread
283 : decoded.drafts.thread;
284 expect(restored?.text).toBe("Review these [notes.txt](t3-context://v1/file/legacy-file) ");
285 expect(restored?.attachments).toEqual(legacy.attachments);
286 expect(restored?.context?.records).toEqual([
287 expect.objectContaining({ kind: "file", attachmentId: file.id }),
288 ]);
289 expect(
290 decodePersistedComposerState({ schemaVersion: 1, drafts: { thread: restored } }).drafts
291 .thread,
292 ).toEqual(restored);
293 appAtomRegistry.set(composerDraftsAtom, { thread: restored! });
294 setComposerDraftText("thread", "Review these");
295 expect(getComposerDraftSnapshot("thread").attachments).toEqual([image, video]);
296 await releaseUnusedComposerAttachmentFiles([file]);
297 },
298 );
299
300 it("restores a missing file reference without duplicating its record or replacing another record's id", () => {
301 const file = {
302 type: "file" as const,
303 id: "file",
304 name: "notes.txt",
305 mimeType: "text/plain",
306 sizeBytes: 10,
307 fileUri: "file:///notes.txt",
308 };
309 const existing = {
310 version: 1,
311 contextId: "original",
312 kind: "file",
313 label: file.name,
314 attachmentId: file.id,
315 name: file.name,
316 mimeType: file.mimeType,
317 sizeBytes: file.sizeBytes,
318 };
319 const skill = { version: 1, contextId: "file", kind: "skill", label: "Skill", name: "skill" };
320 for (const records of [[existing, skill], [skill]]) {
321 const restored = decodePersistedComposerState({
322 schemaVersion: 1,
323 drafts: {
324 thread: { text: "", attachments: [file], context: { version: 1, records } },
325 },
326 }).drafts.thread;
327 expect(restored?.context?.records).toHaveLength(2);
328 expect(restored?.context?.records).toContainEqual(skill);
329 expect(restored?.text).toBe(
330 `[notes.txt](t3-context://v1/file/${records.length === 2 ? "original" : "file_2"}) `,
331 );
332 }
333 });
334 it.each([false, true])(
335 "restores deleted file chips and releases undo history (uploaded: %s)",
336 async (uploaded) => {
337 const key = "environment:undo-file";
338 const file = {
339 type: "file" as const,
340 id: "undo-file",
341 name: "notes.txt",
342 mimeType: "text/plain",
343 sizeBytes: 1,
344 fileUri: "file:///documents/t3-composer-attachments/undo-file.txt",
345 ...(uploaded
346 ? {
347 uploadedAttachmentId: "pending-upload",
348 uploadEnvironmentId: EnvironmentId.make("environment"),
349 }
350 : {}),
351 };
352 appendComposerDraftAttachments(key, [file], { appendReference: true });
353 const original = getComposerDraftSnapshot(key);
354 const history = createComposerDraftContextHistory();
355 const changeText = (text: string) => {
356 const restored = history.restore(text, getComposerDraftSnapshot(key));
357 setComposerDraftText(key, text);
358 setComposerDraftContext(key, restored.context);
359 appendComposerDraftAttachments(key, restored.attachments, { allowOverflow: true });
360 };
361 try {
362 changeText("");
363 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
364 await releaseUnusedComposerAttachmentFiles([file]);
365 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalledWith(file.fileUri);
366 changeText(original.text);
367 expect(getComposerDraftSnapshot(key).context).toEqual(original.context);
368 expect(getComposerDraftSnapshot(key).attachments).toEqual([
369 { ...file, uploadedAttachmentId: undefined, uploadEnvironmentId: undefined },
370 ]);
371 changeText("");
372 } finally {
373 history.dispose();
374 }
375 await releaseUnusedComposerAttachmentFiles([file]);
376 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
377 },
378 );
379
380 it("keeps a long attachment filename and a bounded chip label through reload", () => {
381 const key = "environment:long-file";
382 const file = {
383 type: "file" as const,
384 id: "long-file",
385 name: `${"a".repeat(210)}.txt`,
386 mimeType: "text/plain",
387 sizeBytes: 1,
388 fileUri: "file:///long-file.txt",
389 };
390 appendComposerDraftAttachments(key, [file], { appendReference: true });
391 const draft = getComposerDraftSnapshot(key);
392 const reloaded = decodePersistedComposerState(
393 JSON.parse(
394 JSON.stringify({
395 schemaVersion: 1,
396 drafts: { [key]: draft },
397 }),
398 ),
399 ).drafts[key];
400 expect(reloaded?.context?.records[0]).toMatchObject({ name: file.name, attachmentId: file.id });
401 expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200);
402 });
403
404 it("gives a folded paste a chip that survives the send", () => {
405 const key = "environment-1:thread-1";
406 // What `createPastedTextComposerAttachment` produces for a long paste.
407 const pasted = {
408 type: "file" as const,
409 id: "pasted-1",
410 name: "pasted-text.txt",
411 mimeType: "text/plain",
412 sizeBytes: 40_000,
413 fileUri: "file:///pasted-text.txt",
414 };
415 appendComposerDraftAttachments(key, [pasted], { appendReference: true });
416
417 const draft = getComposerDraftSnapshot(key);
418 // Visible in the composer before sending, not only once the message lands.
419 expect(draft.text).toContain("pasted-text.txt");
420 expect(draft.context?.records).toMatchObject([
421 { kind: "file", attachmentId: pasted.id, name: pasted.name },
422 ]);
423 // The reference points at the record, so the chip stays a chip in the sent message.
424 const [record] = draft.context?.records ?? [];
425 expect(draft.text).toContain(String(record?.contextId));
426 });
427
428 it("drops chips and records for attachments a replace no longer keeps", () => {
429 const key = "new-task:draft-1";
430 const kept = {
431 type: "file" as const,
432 id: "kept-file",
433 name: "kept.txt",
434 mimeType: "text/plain",
435 sizeBytes: 1,
436 fileUri: "file:///kept.txt",
437 };
438 const dropped = {
439 type: "file" as const,
440 id: "dropped-file",
441 name: "dropped.txt",
442 mimeType: "text/plain",
443 sizeBytes: 1,
444 fileUri: "file:///dropped.txt",
445 };
446 appendComposerDraftAttachments(key, [kept, dropped], { appendReference: true });
447
448 const before = getComposerDraftSnapshot(key);
449 expect(before.text).toContain("dropped.txt");
450 expect(before.context?.records).toHaveLength(2);
451
452 replaceComposerDraftAttachments(key, [kept]);
453
454 const after = getComposerDraftSnapshot(key);
455 expect(after.attachments.map((attachment) => attachment.id)).toEqual([kept.id]);
456 expect(after.text).not.toContain("dropped.txt");
457 expect(after.context?.records.map((record) => record.contextId)).toEqual([
458 before.context?.records[0]?.contextId,
459 ]);
460 });
461
462 it.each(["new-task:draft-1", "pending-task:queued-1"])(
463 "finds draft-only local clipboard files in %s",
464 (key) => {
465 const environmentId = EnvironmentId.make("environment-1");
466 const file = {
467 type: "file" as const,
468 id: "local-file",
469 name: "notes.txt",
470 mimeType: "text/plain",
471 sizeBytes: 4,
472 fileUri: "file:///notes.txt",
473 };
474 appAtomRegistry.set(composerDraftsAtom, {
475 [key]: {
476 text: "",
477 attachments: [file],
478 project: {
479 environmentId,
480 projectId: ProjectId.make("project-1"),
481 createdAt: "2026-01-01T00:00:00.000Z",
482 },
483 },
484 });
485 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
486 queued: [
487 {
488 environmentId,
489 threadId: ThreadId.make("thread-1"),
490 messageId: MessageId.make("queued-1"),
491 commandId: CommandId.make("command-1"),
492 text: "Queued",
493 attachments: [],
494 createdAt: "2026-01-01T00:00:00.000Z",
495 },
496 ],
497 });
498 expect(findLocalComposerClipboardAttachment(environmentId, file.id)).toEqual(file);
499 expect(
500 findLocalComposerClipboardAttachment(EnvironmentId.make("different-environment"), file.id),
501 ).toBeUndefined();
502 },
503 );
504
505 it("keeps over-limit recovery context reloadable without losing other drafts", () => {
506 const key = "environment-1:recovery";
507 const merged = mergeComposerDraftContentState(
508 { [key]: contextDraft(0, 200), other: DRAFT },
509 key,
510 contextDraft(200, 200),
511 );
512 expect(merged[key]?.context?.records).toHaveLength(400);
513 const reloaded = decodePersistedComposerState(
514 JSON.parse(JSON.stringify({ schemaVersion: 1, drafts: merged })),
515 ).drafts;
516 expect(reloaded[key]).toEqual(merged[key]);
517 expect(reloaded.other).toEqual(DRAFT);
518 });
519
520 it("prunes unreferenced context during a content merge", () => {
521 const existing = contextDraft(0, 2);
522 const incoming = contextDraft(2, 2);
523 const merged = mergeComposerDraftContentState(
524 { key: { ...existing, text: "[Skill](t3-context://v1/skill/skill-0)" } },
525 "key",
526 { ...incoming, text: "[Skill](t3-context://v1/skill/skill-2)" },
527 );
528 expect(merged.key?.context?.records.map((record) => record.contextId)).toEqual([
529 "skill-0",
530 "skill-2",
531 ]);
532 });
533
534 it("restores and persists both full cloud and live context drafts", async () => {
535 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
536 onTestFinished(() => load.mockRestore());
537 await waitForComposerDraftsLoaded();
538 const key = "environment-1:restored";
539 appAtomRegistry.set(composerDraftsAtom, { [key]: contextDraft(0, 200) });
540 appAtomRegistry.set(composerCloudDraftsAtom, {
541 accountId: null,
542 signedOut: { account: { drafts: { [key]: contextDraft(200, 200) }, queuedMessages: [] } },
543 });
544 await restoreCloudComposerDrafts("account");
545 expect(getComposerDraftSnapshot(key).context?.records).toHaveLength(400);
546 const reloaded = decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument()));
547 expect(reloaded.drafts[key]?.context?.records).toHaveLength(400);
548 expect(reloaded.cloudDrafts.signedOut).toEqual({});
549 });
550
551 it.each([
552 { name: "notes.txt", mimeType: "text/plain" },
553 { name: "document-photo.png", mimeType: "image/png" },
554 { name: "recording.mp4", mimeType: "video/mp4" },
555 ])(
556 "removes $name after its last reference is deleted, while retaining native images",
557 async ({ name, mimeType }) => {
558 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
559 onTestFinished(() => outboxLoad.mockRestore());
560 const cleanup = Promise.withResolvers<void>();
561 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
562 cleanup.resolve();
563 });
564 const key = "environment-1:remove-context-files";
565 const file = {
566 id: "file-1",
567 type: "file" as const,
568 name,
569 mimeType,
570 sizeBytes: 4,
571 fileUri: `file:///${name}`,
572 };
573 const image = {
574 ...file,
575 id: "image-1",
576 type: "image" as const,
577 name: "image.png",
578 mimeType: "image/png",
579 fileUri: "file:///image.png",
580 previewUri: "file:///image.png",
581 };
582 appendComposerDraftAttachments(key, [file, image], { appendReference: true });
583 const fileLink = formatComposerContextReference(
584 getComposerDraftSnapshot(key).context!.records[0]!,
585 );
586 setComposerDraftText(key, `${fileLink} ${fileLink}`);
587 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
588 setComposerDraftText(key, fileLink);
589 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
590 expect(
591 countComposerDraftAttachmentsAfterSelection(key, {
592 text: fileLink,
593 start: 0,
594 end: fileLink.length,
595 }),
596 ).toBe(1);
597 setComposerDraftText(key, "plain text");
598 expect(getComposerDraftSnapshot(key).attachments).toEqual([image]);
599 await cleanup.promise;
600 },
601 );
602
603 it("rejects attachments atomically when no context slots remain", async () => {
604 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
605 onTestFinished(() => outboxLoad.mockRestore());
606 const cleanup = Promise.withResolvers<void>();
607 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
608 cleanup.resolve();
609 });
610 const key = "environment-1:full-context";
611 const records = Array.from({ length: 200 }, (_, index) => ({
612 version: 1 as const,
613 contextId: `ctx-${index}` as never,
614 kind: "skill" as const,
615 label: "Skill",
616 name: "skill",
617 }));
618 appAtomRegistry.set(composerDraftsAtom, {
619 [key]: {
620 text: records
621 .map((record) => `[Skill](t3-context://v1/skill/${record.contextId})`)
622 .join(" "),
623 context: { version: 1, records },
624 attachments: [],
625 },
626 });
627 const before = getComposerDraftSnapshot(key);
628 expect(
629 appendComposerDraftAttachments(
630 key,
631 [
632 {
633 id: "overflow",
634 type: "file",
635 name: "notes.txt",
636 mimeType: "text/plain",
637 sizeBytes: 4,
638 fileUri: "file:///overflow.txt",
639 },
640 ],
641 { appendReference: true },
642 ),
643 ).toBe(1);
644 expect(getComposerDraftSnapshot(key)).toEqual(before);
645 await cleanup.promise;
646 });
647 it.each(["environment-1:thread", "environment-1:new-task:draft"])(
648 "preserves the captured paste selection across async writes in %s",
649 async (key) => {
650 const file = {
651 id: "paste",
652 type: "file" as const,
653 name: "pasted-text.txt",
654 mimeType: "text/plain",
655 sizeBytes: 40_000,
656 fileUri: "file:///paste.txt",
657 };
658 setComposerDraftText(key, "before selected after");
659 const insertion = captureComposerDraftInsertion(key, { start: 7, end: 15 });
660 const write = Promise.withResolvers<typeof file>();
661 const pending = write.promise.then((attachment) =>
662 appendComposerDraftAttachments(key, [attachment], { appendReference: true, insertion }),
663 );
664 rememberComposerDraftSelection(key, insertion.text, { start: 0, end: 6 });
665 rememberComposerDraftSelection("another-draft", "unrelated", { start: 0, end: 9 });
666 write.resolve(file);
667 expect(await pending).toBe(0);
668 const draft = getComposerDraftSnapshot(key);
669 expect(draft.text).toBe("before [pasted-text.txt](t3-context://v1/file/paste) after");
670 expect(draft.attachments).toEqual([file]);
671 },
672 );
673
674 it("preserves edits made while a paste is pending instead of deleting stale offsets", async () => {
675 const key = "environment-1:typing-during-paste";
676 setComposerDraftText(key, "old selection");
677 const insertion = captureComposerDraftInsertion(key, { start: 0, end: 13 });
678 const write = Promise.withResolvers<void>();
679 const pending = write.promise.then(() => insertComposerDraftText(key, " pasted", insertion));
680 setComposerDraftText(key, "keep newly typed text");
681 write.resolve();
682 await pending;
683 expect(getComposerDraftSnapshot(key).text).toBe("keep newly typed text pasted");
684 });
685
686 it.each(["attachment", "context", "imported context"])(
687 "releases a selected file when replaced by %s",
688 async (kind) => {
689 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
690 onTestFinished(() => outboxLoad.mockRestore());
691 const cleanup = Promise.withResolvers<void>();
692 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
693 cleanup.resolve();
694 return undefined;
695 });
696 const key = "environment-1:replace-file";
697 const files = Array.from({ length: 100 }, (_, index) => ({
698 id: `file-${index}`,
699 type: "file" as const,
700 name: `notes-${index}.txt`,
701 mimeType: "text/plain",
702 sizeBytes: 4,
703 fileUri: `file:///notes-${index}.txt`,
704 }));
705 appendComposerDraftAttachments(key, files, { appendReference: true });
706 const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)";
707 const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length });
708 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99);
709 expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99);
710 const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" };
711 if (kind === "attachment") {
712 expect(
713 appendComposerDraftAttachments(key, [replacement], { appendReference: true, insertion }),
714 ).toBe(0);
715 } else if (kind === "imported context") {
716 const record = {
717 version: 1 as const,
718 kind: "file" as const,
719 contextId: ComposerContextId.make("replacement"),
720 attachmentId: replacement.id,
721 label: replacement.name,
722 name: replacement.name,
723 mimeType: replacement.mimeType,
724 sizeBytes: replacement.sizeBytes,
725 };
726 expect(
727 insertComposerDraftContext(
728 key,
729 {
730 text: formatComposerContextReference(record),
731 context: { version: 1, records: [record] },
732 attachments: [replacement],
733 },
734 insertion,
735 ),
736 ).toBe(true);
737 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100);
738 expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record);
739 expect(getComposerDraftSnapshot(key).text).toBe(
740 `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`,
741 );
742 } else {
743 insertComposerDraftContext(
744 key,
745 { text: "replacement", context: { version: 1, records: [] } },
746 insertion,
747 );
748 }
749 const draft = getComposerDraftSnapshot(key);
750 expect(draft.attachments.map((file) => file.id)).not.toContain("file-0");
751 expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1));
752 expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false);
753 await cleanup.promise;
754 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri);
755 },
756 );
757
758 it("rejects an imported file atomically when edits during import use up its replacement slot", async () => {
759 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
760 onTestFinished(() => outboxLoad.mockRestore());
761 const cleanup = Promise.withResolvers<void>();
762 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
763 cleanup.resolve();
764 return undefined;
765 });
766 const key = "environment-1:concurrent-import";
767 const files = Array.from({ length: 100 }, (_, index) => ({
768 id: `existing-${index}`,
769 type: "file" as const,
770 name: `notes-${index}.txt`,
771 mimeType: "text/plain",
772 sizeBytes: 4,
773 fileUri: `file:///notes-${index}.txt`,
774 }));
775 appendComposerDraftAttachments(key, files, { appendReference: true });
776 const firstLink = "[notes-0.txt](t3-context://v1/file/existing-0)";
777 const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length });
778 setComposerDraftText(key, `New edit ${insertion.text}`);
779 const edited = getComposerDraftSnapshot(key);
780 const imported = { ...files[0]!, id: "imported", fileUri: "file:///imported.txt" };
781 const record = {
782 version: 1 as const,
783 kind: "file" as const,
784 contextId: ComposerContextId.make("imported"),
785 attachmentId: imported.id,
786 label: imported.name,
787 name: imported.name,
788 mimeType: imported.mimeType,
789 sizeBytes: imported.sizeBytes,
790 };
791 expect(
792 insertComposerDraftContext(
793 key,
794 {
795 text: formatComposerContextReference(record),
796 context: { version: 1, records: [record] },
797 attachments: [imported],
798 },
799 insertion,
800 ),
801 ).toBe(false);
802 expect(getComposerDraftSnapshot(key)).toEqual(edited);
803 await cleanup.promise;
804 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(imported.fileUri);
805 });
806
807 it("retains a file when replacing only one of its repeated references", () => {
808 const key = "environment-1:repeat-reference";
809 const file = {
810 id: "repeat",
811 type: "file" as const,
812 name: "notes.txt",
813 mimeType: "text/plain",
814 sizeBytes: 4,
815 fileUri: "file:///repeat.txt",
816 };
817 appendComposerDraftAttachments(key, [file], { appendReference: true });
818 const link = getComposerDraftSnapshot(key).text;
819 setComposerDraftText(key, `${link} ${link}`);
820 const insertion = captureComposerDraftInsertion(key, { start: 0, end: link.length });
821 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(1);
822 insertComposerDraftContext(
823 key,
824 { text: "replaced", context: { version: 1, records: [] } },
825 insertion,
826 );
827 expect(getComposerDraftSnapshot(key).attachments).toEqual([file]);
828 expect(getComposerDraftSnapshot(key).text).toBe(`replaced ${link}`);
829 });
830
831 it("inserts context at the saved caret and retains its payload through persistence and restore", () => {
832 const draftKey = "context-environment:context-thread";
833 const record = {
834 version: 1 as const,
835 kind: "terminal" as const,
836 contextId: ComposerContextId.make("context-terminal"),
837 label: "Build output",
838 terminalId: "main",
839 terminalLabel: "Terminal",
840 lineStart: 1,
841 lineEnd: 1,
842 text: "Build failed",
843 };
844 const reference = "[Build output](t3-context://v1/terminal/context-terminal)";
845 setComposerDraftText(draftKey, "Fix this next");
846 rememberComposerDraftSelection(draftKey, "Fix this next", { start: 4, end: 8 });
847 insertComposerDraftContext(draftKey, {
848 text: reference,
849 context: { version: 1, records: [record] },
850 });
851 const draft = getComposerDraftSnapshot(draftKey);
852 expect(draft.text).toBe(`Fix ${reference} next`);
853 const decoded = decodePersistedComposerState(
854 JSON.parse(JSON.stringify({ schemaVersion: 1, drafts: { [draftKey]: draft } })),
855 ).drafts[draftKey];
856 expect(decoded?.context?.records).toEqual([record]);
857 const restored = mergeComposerDraftContentState(
858 { [draftKey]: { text: "Additional work", attachments: [] } },
859 draftKey,
860 decoded!,
861 );
862 expect(restored[draftKey]?.text).toContain(reference);
863 expect(restored[draftKey]?.context?.records).toEqual([record]);
864 expect(clearComposerDraftContentState(restored, draftKey)[draftKey]?.context).toBeUndefined();
865 setComposerDraftText(draftKey, "Fix next");
866 expect(getComposerDraftSnapshot(draftKey).context).toBeUndefined();
867 });
868
869 // Hydration is one-shot per module instance and the attachment sweep now
870 // triggers it too, so this test must observe it before any sweep test runs.
871 it("hydrates generic file attachments from their saved local paths", () => {
872 const file = {
873 id: "file-1",
874 type: "file" as const,
875 name: "report.pdf",
876 mimeType: "application/pdf",
877 sizeBytes: 42,
878 fileUri: "file:///documents/report.pdf",
879 };
880
881 expect(
882 decodePersistedComposerState({
883 schemaVersion: 1,
884 drafts: {
885 "environment-1:thread-1": { text: "Review this file", attachments: [file] },
886 },
887 }).drafts,
888 ).toEqual({
889 "environment-1:thread-1": {
890 text: "Review this file [report.pdf](t3-context://v1/file/file-1) ",
891 attachments: [file],
892 context: {
893 version: 1,
894 records: [
895 {
896 version: 1,
897 contextId: file.id,
898 kind: "file",
899 label: file.name,
900 attachmentId: file.id,
901 name: file.name,
902 mimeType: file.mimeType,
903 sizeBytes: file.sizeBytes,
904 },
905 ],
906 },
907 },
908 });
909 });
910
911 it("releases videos rejected by the live draft limit and keeps accepted files", async () => {
912 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
913 onTestFinished(() => outboxLoad.mockRestore());
914 const cleanup = Promise.withResolvers<void>();
915 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
916 cleanup.resolve();
917 });
918 const makeAttachment = (id: string) => ({
919 id,
920 type: "file" as const,
921 name: `${id}.mov`,
922 mimeType: "video/quicktime",
923 sizeBytes: 42,
924 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
925 });
926 const draftKey = "new-task:environment-1:project-cap";
927 const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`));
928 appAtomRegistry.set(composerDraftsAtom, {
929 [draftKey]: { text: "send this", attachments: existing },
930 });
931
932 const rejected = appendComposerDraftAttachments(draftKey, [
933 makeAttachment("incoming-1"),
934 makeAttachment("incoming-2"),
935 ]);
936
937 expect(rejected).toBe(1);
938 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
939 expect(draft?.attachments).toHaveLength(100);
940 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
941 await cleanup.promise;
942 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
943 makeAttachment("incoming-2").fileUri,
944 );
945
946 // Restore paths bypass the cap so a failed send never drops its files.
947 const overflowRejected = appendComposerDraftAttachments(
948 draftKey,
949 [makeAttachment("restored-1")],
950 { allowOverflow: true },
951 );
952 expect(overflowRejected).toBe(0);
953 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101);
954 });
955
956 it("keeps shared attachment files until every draft releases them", async () => {
957 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
958 onTestFinished(() => outboxLoad.mockRestore());
959 const file = {
960 id: "file-1",
961 type: "file" as const,
962 name: "report.pdf",
963 mimeType: "application/pdf",
964 sizeBytes: 42,
965 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
966 };
967 appAtomRegistry.set(composerDraftsAtom, {
968 source: { text: "First draft", attachments: [file] },
969 copied: { text: "Second draft", attachments: [file] },
970 });
971
972 await releaseUnusedComposerAttachmentFiles([file]);
973 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
974
975 appAtomRegistry.set(composerDraftsAtom, {
976 copied: { text: "Second draft", attachments: [file] },
977 });
978 await releaseUnusedComposerAttachmentFiles([file]);
979 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
980
981 appAtomRegistry.set(composerDraftsAtom, {});
982 await releaseUnusedComposerAttachmentFiles([file]);
983 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
984 });
985
986 it("retains a referenced file-backed image and releases it once unreferenced", async () => {
987 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
988 onTestFinished(() => outboxLoad.mockRestore());
989 const image = {
990 id: "image-1",
991 type: "image" as const,
992 name: "photo.png",
993 mimeType: "image/png",
994 sizeBytes: 3,
995 fileUri: "file:///documents/t3-composer-attachments/photo.png",
996 previewUri: "file:///documents/t3-composer-attachments/photo.png",
997 };
998 appAtomRegistry.set(composerDraftsAtom, {
999 "environment-1:thread-1": { text: "look at this", attachments: [image] },
1000 });
1001
1002 await releaseUnusedComposerAttachmentFiles([image]);
1003 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1004
1005 // A queued outbox message must keep the bytes alive after the draft clears.
1006 appAtomRegistry.set(composerDraftsAtom, {});
1007 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
1008 "environment-1:thread-1": [
1009 {
1010 environmentId: EnvironmentId.make("environment-1"),
1011 threadId: ThreadId.make("thread-1"),
1012 messageId: MessageId.make("queued-image"),
1013 commandId: CommandId.make("command-image"),
1014 text: "look at this",
1015 attachments: [image],
1016 createdAt: "2026-08-31T12:00:00.000Z",
1017 },
1018 ],
1019 });
1020 await releaseUnusedComposerAttachmentFiles([image]);
1021 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1022
1023 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
1024 await releaseUnusedComposerAttachmentFiles([image]);
1025 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri);
1026 });
1027
1028 it("keeps an image through an inline read after its draft is removed", async () => {
1029 const { prepareTurnAttachments } =
1030 await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload");
1031 const image = {
1032 id: "image-reading",
1033 type: "image" as const,
1034 name: "photo.png",
1035 mimeType: "image/png",
1036 sizeBytes: 3,
1037 fileUri: "file:///documents/t3-composer-attachments/photo.png",
1038 previewUri: "file:///documents/t3-composer-attachments/photo.png",
1039 };
1040 const readStarted = Promise.withResolvers<void>();
1041 const read = Promise.withResolvers<void>();
1042 const removed = Promise.withResolvers<void>();
1043 let imageExists = true;
1044 composerDraftFileMocks.readImage.mockImplementation(async () => {
1045 readStarted.resolve();
1046 await read.promise;
1047 if (!imageExists) throw new Error("The image was deleted during its read");
1048 return "YWJj";
1049 });
1050 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
1051 imageExists = false;
1052 removed.resolve();
1053 });
1054 appendComposerDraftAttachments("environment-1:thread-1", [image]);
1055 await flushComposerDrafts();
1056 const preparing = prepareTurnAttachments({
1057 environmentId: EnvironmentId.make("environment-1"),
1058 attachments: [image],
1059 });
1060 onTestFinished(async () => {
1061 read.resolve();
1062 await preparing.catch(() => undefined);
1063 });
1064 await readStarted.promise;
1065 clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true });
1066 await releaseUnusedComposerAttachmentFiles([image]);
1067 expect(imageExists).toBe(true);
1068
1069 read.resolve();
1070 const prepared = await preparing;
1071 expect(prepared.status).toBe("ready");
1072 if (prepared.status !== "ready") return;
1073 expect(prepared.attachments).toEqual([
1074 {
1075 type: "image",
1076 name: "photo.png",
1077 mimeType: "image/png",
1078 sizeBytes: 3,
1079 dataUrl: "data:image/png;base64,YWJj",
1080 },
1081 ]);
1082 await removed.promise;
1083 expect(imageExists).toBe(false);
1084 });
1085
1086 it("keeps a failed-send draft's pending upload for retry", async () => {
1087 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1088 onTestFinished(() => outboxLoad.mockRestore());
1089 const file = {
1090 id: "file-failed-send",
1091 type: "file" as const,
1092 name: "report.pdf",
1093 mimeType: "application/pdf",
1094 sizeBytes: 42,
1095 fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf",
1096 uploadedAttachmentId: "pending-failed-send",
1097 uploadEnvironmentId: EnvironmentId.make("environment-1"),
1098 };
1099 appAtomRegistry.set(composerDraftsAtom, {
1100 "environment-1:thread-1": { text: "Retry this send", attachments: [file] },
1101 });
1102
1103 await releaseUnusedComposerAttachmentFiles([file]);
1104
1105 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1106 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
1107 });
1108
1109 it("retains offline image bytes and newer edits when an early upload finishes", async () => {
1110 const key = "environment-1:thread-1";
1111 const image = {
1112 id: "photo",
1113 type: "image" as const,
1114 name: "photo.png",
1115 mimeType: "image/png",
1116 sizeBytes: 3,
1117 dataUrl: "data:image/png;base64,YWJj",
1118 previewUri: "file:///photo.png",
1119 };
1120 const second = { ...image, id: "second", name: "second.png" };
1121 const uploaded = {
1122 ...image,
1123 uploadedAttachmentId: "pending-photo",
1124 uploadEnvironmentId: EnvironmentId.make("environment-1"),
1125 };
1126 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
1127 appendComposerDraftAttachments(key, [image]);
1128 setComposerDraftText(key, "Edited while uploading");
1129 appendComposerDraftAttachments(key, [second]);
1130 expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true);
1131 await flushComposerDrafts();
1132
1133 appAtomRegistry.set(composerDraftsAtom, {});
1134 resetComposerDraftsLoadState();
1135 await waitForComposerDraftsLoaded();
1136 expect(getComposerDraftSnapshot(key)).toMatchObject({
1137 text: "Edited while uploading",
1138 attachments: [uploaded, second],
1139 });
1140 expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false);
1141 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2);
1142 });
1143
1144 it("cleans up an unreferenced image upload even when there is no local file URI", async () => {
1145 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1146 onTestFinished(() => outboxLoad.mockRestore());
1147 const environmentId = EnvironmentId.make("environment-1");
1148 await releaseUnusedComposerAttachmentFiles([
1149 {
1150 id: "photo",
1151 type: "image",
1152 name: "photo.png",
1153 mimeType: "image/png",
1154 sizeBytes: 3,
1155 dataUrl: "data:image/png;base64,YWJj",
1156 previewUri: "file:///photo.png",
1157 uploadedAttachmentId: "pending-photo",
1158 uploadEnvironmentId: environmentId,
1159 },
1160 ]);
1161 expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [
1162 "pending-photo",
1163 ]);
1164 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1165 });
1166
1167 it.each(["file", "image"] as const)(
1168 "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account",
1169 async (type) => {
1170 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1171 onTestFinished(() => load.mockRestore());
1172 await waitForComposerDraftsLoaded();
1173 const environmentId = EnvironmentId.make("cloud-environment");
1174 const key = `${environmentId}:thread-1`;
1175 const name = type === "image" ? "notes.png" : "notes.pdf";
1176 const metadata = {
1177 id: "local-notes",
1178 name,
1179 mimeType: type === "image" ? "image/png" : "application/pdf",
1180 sizeBytes: 42,
1181 fileUri: `file:///documents/t3-composer-attachments/${name}`,
1182 uploadEnvironmentId: environmentId,
1183 uploadedAttachmentId: "pending-notes",
1184 };
1185 const file =
1186 type === "image"
1187 ? { ...metadata, type, previewUri: metadata.fileUri }
1188 : { ...metadata, type };
1189 const queued = {
1190 environmentId,
1191 threadId: ThreadId.make("thread-2"),
1192 messageId: MessageId.make("queued-1"),
1193 commandId: CommandId.make("command-1"),
1194 text: "Send later",
1195 attachments: [file],
1196 createdAt: "2026-08-31T12:00:00.000Z",
1197 };
1198 appAtomRegistry.set(composerDraftsAtom, {
1199 [key]: { text: "Unsent notes", attachments: [file] },
1200 "direct-environment:thread-1": DRAFT,
1201 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },
1202 });
1203 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });
1204 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));
1205 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
1206 "direct-environment:thread-1": DRAFT,
1207 });
1208 // The registry can remove the active outbox and drafts after the backup lands.
1209 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
1210 await clearComposerDraftsEnvironment(environmentId);
1211 await releaseUnusedComposerAttachmentFiles([file]);
1212 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1213 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
1214
1215 appAtomRegistry.set(composerDraftsAtom, {});
1216 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
1217 resetComposerDraftsLoadState();
1218 await waitForComposerDraftsLoaded();
1219 await restoreCloudComposerDrafts("account-b");
1220 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
1221 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});
1222 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
1223 onTestFinished(() => enqueue.mockRestore());
1224 await restoreCloudComposerDrafts("account-a");
1225 expect(getComposerDraftSnapshot(key)).toEqual(
1226 type === "image"
1227 ? { text: "Unsent notes", attachments: [file] }
1228 : {
1229 text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ",
1230 attachments: [file],
1231 context: {
1232 version: 1,
1233 records: [
1234 {
1235 version: 1,
1236 contextId: file.id,
1237 kind: "file",
1238 label: file.name,
1239 attachmentId: file.id,
1240 name: file.name,
1241 mimeType: file.mimeType,
1242 sizeBytes: file.sizeBytes,
1243 },
1244 ],
1245 },
1246 },
1247 );
1248 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe(
1249 type === "image"
1250 ? "Edited queued task"
1251 : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ",
1252 );
1253 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
1254 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
1255 const persisted = decodePersistedComposerState(
1256 JSON.parse(composerDraftFileMocks.getDocument()),
1257 );
1258 expect(persisted.drafts[key]?.attachments).toEqual([file]);
1259 expect(persisted.cloudDrafts.accountId).toBe("account-a");
1260 },
1261 );
1262
1263 it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => {
1264 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1265 onTestFinished(() => load.mockRestore());
1266 await waitForComposerDraftsLoaded();
1267 appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT });
1268 composerDraftFileMocks.setWriteError(new Error("Storage is full"));
1269 await expect(
1270 archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])),
1271 ).rejects.toThrow();
1272 expect(
1273 appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[
1274 "environment-1:thread-1"
1275 ],
1276 ).toEqual(DRAFT);
1277 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1278 composerDraftFileMocks.setWriteError(null);
1279 await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")]));
1280 expect(
1281 decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts
1282 .signedOut["account-a"]?.drafts["environment-1:thread-1"],
1283 ).toEqual(DRAFT);
1284 });
1285
1286 it.each(["file", "image"] as const)(
1287 "keeps a removed %s until both preview and a share copy finish",
1288 async (type) => {
1289 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1290 onTestFinished(() => outboxLoad.mockRestore());
1291 const name = type === "image" ? "photo.png" : "recording.mp4";
1292 const fileName = `33333333-3333-4333-8333-333333333333-${name}`;
1293 const metadata = {
1294 id: "file-preview",
1295 name,
1296 mimeType: type === "image" ? "image/png" : "video/mp4",
1297 sizeBytes: 42,
1298 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
1299 };
1300 const file =
1301 type === "image"
1302 ? { ...metadata, type, previewUri: metadata.fileUri }
1303 : { ...metadata, type };
1304 const currentFile = {
1305 ...file,
1306 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
1307 };
1308 const releasePlayback = retainComposerAttachmentFileForPreview(file);
1309 const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);
1310 onTestFinished(releasePlayback);
1311 onTestFinished(releaseShareCopy);
1312
1313 await releaseUnusedComposerAttachmentFiles([currentFile]);
1314 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1315
1316 releasePlayback();
1317 releasePlayback();
1318 await releaseUnusedComposerAttachmentFiles([file]);
1319 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1320
1321 const deleted = Promise.withResolvers<void>();
1322 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
1323 deleted.resolve();
1324 return undefined;
1325 });
1326 releaseShareCopy();
1327 await deleted.promise;
1328
1329 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);
1330 },
1331 );
1332
1333 it("preserves a preview opened while cleanup is checking the incoming inbox", async () => {
1334 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1335 onTestFinished(() => outboxLoad.mockRestore());
1336 const file = {
1337 id: "file-opening-preview",
1338 type: "file" as const,
1339 name: "recording.mp4",
1340 mimeType: "video/mp4",
1341 sizeBytes: 42,
1342 fileUri: "file:///documents/t3-composer-attachments/recording.mp4",
1343 };
1344 const ownershipReadStarted = Promise.withResolvers<void>();
1345 const ownershipRead = Promise.withResolvers<[]>();
1346 incomingShareStorageMocks.load.mockImplementationOnce(() => {
1347 ownershipReadStarted.resolve();
1348 return ownershipRead.promise;
1349 });
1350
1351 const cleanup = releaseUnusedComposerAttachmentFiles([file]);
1352 await ownershipReadStarted.promise;
1353 const release = retainComposerAttachmentFileForPreview(file);
1354 onTestFinished(release);
1355 ownershipRead.resolve([]);
1356 await cleanup;
1357 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1358
1359 const deleted = Promise.withResolvers<void>();
1360 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
1361 deleted.resolve();
1362 return undefined;
1363 });
1364 release();
1365 await deleted.promise;
1366 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]);
1367 });
1368
1369 it("removes an unreferenced local file and its pending upload", async () => {
1370 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1371 onTestFinished(() => outboxLoad.mockRestore());
1372 const environmentId = EnvironmentId.make("environment-1");
1373 const file = {
1374 id: "file-discarded",
1375 type: "file" as const,
1376 name: "report.pdf",
1377 mimeType: "application/pdf",
1378 sizeBytes: 42,
1379 fileUri: "file:///documents/t3-composer-attachments/discarded.pdf",
1380 uploadedAttachmentId: "pending-discarded",
1381 uploadEnvironmentId: environmentId,
1382 };
1383
1384 await releaseUnusedComposerAttachmentFiles([file]);
1385
1386 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
1387 expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [
1388 "pending-discarded",
1389 ]);
1390 });
1391
1392 it("keeps a pending upload referenced through another local file", async () => {
1393 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1394 onTestFinished(() => outboxLoad.mockRestore());
1395 const environmentId = EnvironmentId.make("environment-1");
1396 const discarded = {
1397 id: "file-discarded-copy",
1398 type: "file" as const,
1399 name: "report.pdf",
1400 mimeType: "application/pdf",
1401 sizeBytes: 42,
1402 fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf",
1403 uploadedAttachmentId: "pending-shared",
1404 uploadEnvironmentId: environmentId,
1405 };
1406 const retained = {
1407 ...discarded,
1408 id: "file-retained-copy",
1409 fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf",
1410 };
1411 appAtomRegistry.set(composerDraftsAtom, {
1412 "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] },
1413 });
1414
1415 await releaseUnusedComposerAttachmentFiles([discarded]);
1416
1417 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri);
1418 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
1419 });
1420
1421 it("completes local cleanup when pending upload deletion fails", async () => {
1422 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1423 onTestFinished(() => outboxLoad.mockRestore());
1424 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1425 onTestFinished(() => warning.mockRestore());
1426 composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce(
1427 new Error("environment disconnected"),
1428 );
1429 const file = {
1430 id: "file-delete-failed",
1431 type: "file" as const,
1432 name: "report.pdf",
1433 mimeType: "application/pdf",
1434 sizeBytes: 42,
1435 fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf",
1436 uploadedAttachmentId: "pending-delete-failed",
1437 uploadEnvironmentId: EnvironmentId.make("environment-1"),
1438 };
1439
1440 await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined();
1441
1442 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
1443 expect(warning).toHaveBeenCalledWith(
1444 "[composer-attachments] could not remove pending upload",
1445 expect.objectContaining({ attachmentId: "pending-delete-failed" }),
1446 );
1447 });
1448
1449 it("keeps local attachment files while an outbox message still needs them", async () => {
1450 const file = {
1451 id: "file-queued",
1452 type: "file" as const,
1453 name: "report.pdf",
1454 mimeType: "application/pdf",
1455 sizeBytes: 42,
1456 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
1457 };
1458 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
1459 "environment-1:thread-1": [
1460 {
1461 environmentId: EnvironmentId.make("environment-1"),
1462 threadId: ThreadId.make("thread-1"),
1463 messageId: MessageId.make("message-1"),
1464 commandId: CommandId.make("command-1"),
1465 text: "Review the report",
1466 attachments: [file],
1467 createdAt: "2026-08-24T12:00:00.000Z",
1468 },
1469 ],
1470 });
1471
1472 await releaseUnusedComposerAttachmentFiles([file]);
1473
1474 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1475 });
1476
1477 it("loads persisted outbox messages before deciding an attachment file is unused", async () => {
1478 const file = {
1479 id: "file-persisted",
1480 type: "file" as const,
1481 name: "report.pdf",
1482 mimeType: "application/pdf",
1483 sizeBytes: 42,
1484 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
1485 };
1486 const load = vi.spyOn(threadOutboxManager, "load").mockImplementation(async () => {
1487 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
1488 "environment-1:thread-1": [
1489 {
1490 environmentId: EnvironmentId.make("environment-1"),
1491 threadId: ThreadId.make("thread-1"),
1492 messageId: MessageId.make("message-persisted"),
1493 commandId: CommandId.make("command-persisted"),
1494 text: "Review the report",
1495 attachments: [file],
1496 createdAt: "2026-08-24T12:00:00.000Z",
1497 },
1498 ],
1499 });
1500 return true;
1501 });
1502
1503 try {
1504 await releaseUnusedComposerAttachmentFiles([file]);
1505
1506 expect(load).toHaveBeenCalledOnce();
1507 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1508 } finally {
1509 load.mockRestore();
1510 }
1511 });
1512
1513 it("keeps a file until its incoming share is consumed", async () => {
1514 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1515 onTestFinished(() => outboxLoad.mockRestore());
1516 const file = {
1517 id: "file-incoming",
1518 type: "file" as const,
1519 name: "report.pdf",
1520 mimeType: "application/pdf",
1521 sizeBytes: 42,
1522 fileUri: "file:///documents/t3-composer-attachments/incoming.pdf",
1523 };
1524 incomingShareStorageMocks.load
1525 .mockResolvedValueOnce([
1526 {
1527 schemaVersion: 1,
1528 id: "share-1",
1529 createdAt: "2026-08-28T12:00:00.000Z",
1530 text: "Review this file",
1531 attachments: [file],
1532 warnings: [],
1533 },
1534 ])
1535 .mockResolvedValueOnce([]);
1536
1537 await releaseUnusedComposerAttachmentFiles([file]);
1538
1539 expect(incomingShareStorageMocks.load).toHaveBeenLastCalledWith({ strict: true });
1540 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1541
1542 await releaseUnusedComposerAttachmentFiles([file]);
1543
1544 expect(incomingShareStorageMocks.load).toHaveBeenCalledTimes(2);
1545 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
1546 });
1547
1548 it("does not delete files when incoming share ownership cannot be loaded", async () => {
1549 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1550 onTestFinished(() => outboxLoad.mockRestore());
1551 const file = {
1552 id: "file-incoming-unknown",
1553 type: "file" as const,
1554 name: "report.pdf",
1555 mimeType: "application/pdf",
1556 sizeBytes: 42,
1557 fileUri: "file:///documents/t3-composer-attachments/incoming-unknown.pdf",
1558 };
1559 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1560 incomingShareStorageMocks.load.mockRejectedValueOnce(new Error("inbox unavailable"));
1561 onTestFinished(() => warning.mockRestore());
1562
1563 await releaseUnusedComposerAttachmentFiles([file]);
1564
1565 expect(incomingShareStorageMocks.load).toHaveBeenCalledWith({ strict: true });
1566 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1567 });
1568
1569 it.each(["draft", "outbox", "inbox"] as const)(
1570 "preserves relocated files still referenced by a persisted %s",
1571 async (owner) => {
1572 const fileName = "33333333-3333-4333-8333-333333333333-report.pdf";
1573 const oldFile = {
1574 id: "file-relocated",
1575 type: "file" as const,
1576 name: "report.pdf",
1577 mimeType: "application/pdf",
1578 sizeBytes: 42,
1579 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
1580 };
1581 const currentFile = {
1582 ...oldFile,
1583 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
1584 };
1585 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
1586 onTestFinished(() => outboxLoad.mockRestore());
1587 if (owner === "draft") {
1588 composerDraftFileMocks.setDocument({
1589 schemaVersion: 1,
1590 drafts: { "environment-1:thread-1": { text: "Saved draft", attachments: [oldFile] } },
1591 });
1592 resetComposerDraftsLoadState();
1593 } else if (owner === "outbox") {
1594 outboxLoad.mockImplementation(async () => {
1595 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
1596 "environment-1:thread-1": [
1597 {
1598 environmentId: EnvironmentId.make("environment-1"),
1599 threadId: ThreadId.make("thread-1"),
1600 messageId: MessageId.make("message-relocated"),
1601 commandId: CommandId.make("command-relocated"),
1602 text: "Queued draft",
1603 attachments: [oldFile],
1604 createdAt: "2026-08-28T12:00:00.000Z",
1605 },
1606 ],
1607 });
1608 return true;
1609 });
1610 } else {
1611 incomingShareStorageMocks.load.mockResolvedValue([
1612 {
1613 schemaVersion: 1,
1614 id: "share-relocated",
1615 createdAt: "2026-08-28T12:00:00.000Z",
1616 text: "Incoming file",
1617 attachments: [oldFile],
1618 warnings: [],
1619 },
1620 ]);
1621 }
1622
1623 await releaseUnusedComposerAttachmentFiles([currentFile]);
1624
1625 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1626
1627 appAtomRegistry.set(composerDraftsAtom, {});
1628 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
1629 outboxLoad.mockResolvedValue(true);
1630 incomingShareStorageMocks.load.mockResolvedValue([]);
1631 await releaseUnusedComposerAttachmentFiles([currentFile]);
1632
1633 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(currentFile.fileUri);
1634 },
1635 );
1636
1637 it("does not delete attachment files when the draft removal cannot be saved", async () => {
1638 const file = {
1639 id: "file-unsaved",
1640 type: "file" as const,
1641 name: "report.pdf",
1642 mimeType: "application/pdf",
1643 sizeBytes: 42,
1644 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
1645 };
1646 setComposerDraftText("environment-1:thread-1", "Unsaved draft");
1647 composerDraftFileMocks.setWriteError(new Error("storage unavailable"));
1648
1649 try {
1650 await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toBeInstanceOf(
1651 ComposerDraftPersistenceError,
1652 );
1653 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
1654 } finally {
1655 composerDraftFileMocks.setWriteError(null);
1656 }
1657 });
1658
1659 it("rejects persisted images without image bytes or a file URI", () => {
1660 expect(() =>
1661 decodePersistedComposerState({
1662 schemaVersion: 1,
1663 drafts: {
1664 "environment-1:thread-1": {
1665 text: "Saved image",
1666 attachments: [
1667 {
1668 id: "image-1",
1669 type: "image",
1670 name: "photo.png",
1671 mimeType: "image/png",
1672 sizeBytes: 3,
1673 previewUri: "file:///photo.png",
1674 },
1675 ],
1676 },
1677 },
1678 }),
1679 ).toThrow();
1680 });
1681
1682 it("hydrates selector state even when the message content is empty", () => {
1683 const hydrated = Object.entries(
1684 decodePersistedComposerState({
1685 schemaVersion: 1,
1686 drafts: {
1687 "new-task:environment-1:project-1": {
1688 text: "",
1689 attachments: [],
1690 modelSelection: {
1691 instanceId: "codex",
1692 model: "gpt-5.4",
1693 options: [{ id: "reasoningEffort", value: "xhigh" }],
1694 },
1695 runtimeMode: "approval-required",
1696 interactionMode: "plan",
1697 workspaceSelection: {
1698 mode: "worktree",
1699 branch: "main",
1700 worktreePath: null,
1701 },
1702 },
1703 },
1704 }).drafts,
1705 );
1706 expect(hydrated).toHaveLength(1);
1707 const [key, draft] = hydrated[0]!;
1708 // Legacy project keys are rewritten to id keys on load.
1709 expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
1710 expect(draft).toEqual({
1711 text: "",
1712 attachments: [],
1713 modelSelection: {
1714 instanceId: "codex",
1715 model: "gpt-5.4",
1716 options: [{ id: "reasoningEffort", value: "xhigh" }],
1717 },
1718 runtimeMode: "approval-required",
1719 interactionMode: "plan",
1720 workspaceSelection: {
1721 mode: "worktree",
1722 branch: "main",
1723 worktreePath: null,
1724 },
1725 project: {
1726 environmentId: "environment-1",
1727 projectId: "project-1",
1728 createdAt: expect.any(String),
1729 },
1730 });
1731 });
1732 it("keeps legacy content-only drafts and rejects invalid selector state", () => {
1733 expect(
1734 decodePersistedComposerState({
1735 schemaVersion: 1,
1736 drafts: {
1737 "environment-1:thread-1": DRAFT,
1738 },
1739 }).drafts,
1740 ).toEqual({
1741 "environment-1:thread-1": DRAFT,
1742 });
1743
1744 expect(() =>
1745 decodePersistedComposerState({
1746 schemaVersion: 1,
1747 drafts: {
1748 "environment-1:thread-1": {
1749 ...DRAFT,
1750 runtimeMode: "sometimes-safe",
1751 },
1752 },
1753 }),
1754 ).toThrow();
1755 });
1756
1757 it("keeps share-import receipts on otherwise contentless new-task drafts", () => {
1758 const receiptDraft: ComposerDraft = {
1759 text: "",
1760 attachments: [],
1761 importedShareIds: ["share-1"],
1762 };
1763 // The stale-model strip must not touch receipt-bearing drafts, and the
1764 // empty filter must keep them — or the same share would re-import after
1765 // restart.
1766 const stripped = Object.values(
1767 decodePersistedComposerState({
1768 schemaVersion: 1,
1769 drafts: {
1770 "new-task:environment-1:project-1": {
1771 ...receiptDraft,
1772 modelSelection: {
1773 instanceId: "codex",
1774 model: "gpt-5.4",
1775 },
1776 },
1777 },
1778 }).drafts,
1779 );
1780 expect(stripped).toHaveLength(1);
1781 expect(stripped[0]).toMatchObject({
1782 text: "",
1783 attachments: [],
1784 importedShareIds: ["share-1"],
1785 project: { environmentId: "environment-1", projectId: "project-1" },
1786 });
1787 expect(stripped[0]?.modelSelection).toBeUndefined();
1788
1789 const kept = Object.values(
1790 decodePersistedComposerState({
1791 schemaVersion: 1,
1792 drafts: { "new-task:environment-1:project-1": receiptDraft },
1793 }).drafts,
1794 );
1795 expect(kept).toHaveLength(1);
1796 expect(kept[0]).toMatchObject(receiptDraft);
1797 });
1798
1799 it("migrates archived signed-out new-task drafts the same way as live ones", () => {
1800 const decoded = decodePersistedComposerState({
1801 schemaVersion: 1,
1802 drafts: {},
1803 cloudAccountId: "account-1",
1804 signedOutDrafts: {
1805 "account-1": {
1806 drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } },
1807 queuedMessages: [],
1808 },
1809 },
1810 });
1811 const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {});
1812 expect(archived).toHaveLength(1);
1813 expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/);
1814 expect(archived[0]?.[1]).toMatchObject({
1815 text: "archived",
1816 project: { environmentId: "environment-1", projectId: "project-1" },
1817 });
1818 });
1819
1820 it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => {
1821 const now = "2026-09-05T12:00:00.000Z";
1822 const [key, draft] = migrateLegacyNewTaskDraft(
1823 "new-task:environment-1:project-1",
1824 { text: "keep me", attachments: [] },
1825 now,
1826 );
1827 // The new key has no colon after the prefix, so it can never be
1828 // mistaken for the legacy shape on the next load.
1829 expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
1830 expect(draft).toEqual({
1831 text: "keep me",
1832 attachments: [],
1833 project: {
1834 environmentId: EnvironmentId.make("environment-1"),
1835 projectId: ProjectId.make("project-1"),
1836 createdAt: now,
1837 },
1838 });
1839
1840 // Already-migrated, thread, and pending-task keys pass through untouched.
1841 const stamped: ComposerDraft = {
1842 text: "x",
1843 attachments: [],
1844 project: {
1845 environmentId: EnvironmentId.make("environment-1"),
1846 projectId: ProjectId.make("project-1"),
1847 createdAt: now,
1848 },
1849 };
1850 expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([
1851 "new-task:some-id",
1852 stamped,
1853 ]);
1854 expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([
1855 "environment-1:thread-1",
1856 DRAFT,
1857 ]);
1858 expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([
1859 "pending-task:message-1",
1860 DRAFT,
1861 ]);
1862 });
1863
1864 it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => {
1865 const project = {
1866 environmentId: EnvironmentId.make("environment-1"),
1867 projectId: ProjectId.make("project-1"),
1868 };
1869 const first = createNewTaskDraft(project);
1870 const second = createNewTaskDraft(project);
1871 expect(first).not.toBe(second);
1872 // Empty stamped drafts stay in memory so the composer has a key to write
1873 // to, but the persisted document leaves them out.
1874 expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project);
1875
1876 setComposerDraftText(first, "first idea");
1877 setComposerDraftText(second, "second idea");
1878 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual(
1879 expect.arrayContaining([first, second]),
1880 );
1881
1882 // Clearing content on the way out drops the stamp with it.
1883 clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true });
1884 expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined();
1885 expect(getComposerDraftSnapshot(second).text).toBe("second idea");
1886 });
1887
1888 it("retargets a new-task draft to another project without losing its text", () => {
1889 const from = {
1890 environmentId: EnvironmentId.make("environment-1"),
1891 projectId: ProjectId.make("project-1"),
1892 };
1893 const to = {
1894 environmentId: EnvironmentId.make("environment-2"),
1895 projectId: ProjectId.make("project-2"),
1896 };
1897 const key = createNewTaskDraft(from);
1898 setComposerDraftText(key, "moving house");
1899 appAtomRegistry.set(composerDraftsAtom, {
1900 ...appAtomRegistry.get(composerDraftsAtom),
1901 [key]: {
1902 ...getComposerDraftSnapshot(key),
1903 runtimeMode: "approval-required",
1904 workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null },
1905 },
1906 });
1907 const createdAt = getComposerDraftSnapshot(key).project?.createdAt;
1908
1909 retargetNewTaskDraft(key, to);
1910
1911 const moved = getComposerDraftSnapshot(key);
1912 expect(moved.text).toBe("moving house");
1913 expect(moved.runtimeMode).toBe("approval-required");
1914 // Branch and worktree belong to the old repo.
1915 expect(moved.workspaceSelection).toBeUndefined();
1916 expect(moved.project).toEqual({ ...to, createdAt });
1917 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]);
1918 expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]);
1919 });
1920
1921 it("hydrates the global sticky model selection", () => {
1922 expect(
1923 decodePersistedComposerState({
1924 schemaVersion: 1,
1925 drafts: {},
1926 stickyModelSelection: {
1927 instanceId: "codex",
1928 model: "gpt-5.6-sol",
1929 },
1930 }).stickyModelSelection,
1931 ).toEqual({
1932 instanceId: "codex",
1933 model: "gpt-5.6-sol",
1934 });
1935 });
1936
1937 it("decodes model option memory from the composer document", () => {
1938 expect(
1939 decodePersistedComposerState({
1940 schemaVersion: 1,
1941 drafts: {},
1942 modelOptionMemory: {
1943 pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] },
1944 },
1945 }).modelOptionMemory,
1946 ).toEqual({ pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] } });
1947 });
1948
1949 it("merges persisted option memory without replacing newer choices", async () => {
1950 composerDraftFileMocks.setDocument({
1951 schemaVersion: 1,
1952 drafts: {},
1953 modelOptionMemory: {
1954 pi: {
1955 "xai/grok-4.6": [{ id: "thinking", value: "high" }],
1956 "openai/gpt-5.4": [{ id: "thinking", value: "medium" }],
1957 },
1958 },
1959 });
1960 appAtomRegistry.set(modelOptionMemoryAtom, {
1961 pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] },
1962 });
1963
1964 ensureComposerDraftsLoaded();
1965 await waitForComposerDraftsLoaded();
1966
1967 expect(appAtomRegistry.get(modelOptionMemoryAtom)).toEqual({
1968 pi: {
1969 "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }],
1970 "openai/gpt-5.4": [{ id: "thinking", value: "medium" }],
1971 },
1972 });
1973 });
1974
1975 it("waits for hydration before persisting the latest composer state", async () => {
1976 vi.useFakeTimers();
1977 composerDraftFileMocks.setDocument({
1978 schemaVersion: 1,
1979 drafts: {
1980 "environment-1:thread-1": DRAFT,
1981 },
1982 stickyModelSelection: {
1983 instanceId: "codex",
1984 model: "gpt-5.6-sol",
1985 },
1986 });
1987 composerDraftFileMocks.blockRead();
1988 composerDraftFileMocks.resetWrites();
1989
1990 ensureComposerDraftsLoaded();
1991 await Promise.resolve();
1992 // The read is blocked, hydration is pending.
1993 setComposerDraftText("new-task:environment-1:project-1", "New prompt");
1994 await vi.advanceTimersByTimeAsync(200);
1995
1996 // Write should still be deferred — hydration has not resolved.
1997 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
1998
1999 composerDraftFileMocks.releaseRead();
2000 // Let the loadPromise settle and chain into the deferred persist.
2001 await vi.runAllTimersAsync();
2002
2003 expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({
2004 schemaVersion: 1,
2005 drafts: {
2006 "environment-1:thread-1": DRAFT,
2007 "new-task:environment-1:project-1": {
2008 text: "New prompt",
2009 attachments: [],
2010 },
2011 },
2012 stickyModelSelection: {
2013 instanceId: "codex",
2014 model: "gpt-5.6-sol",
2015 },
2016 });
2017 });
2018
2019 it("flush waits for pending hydration instead of clobbering disk", async () => {
2020 vi.useFakeTimers();
2021 composerDraftFileMocks.setDocument({
2022 schemaVersion: 1,
2023 drafts: {
2024 "environment-1:thread-1": DRAFT,
2025 },
2026 stickyModelSelection: {
2027 instanceId: "codex",
2028 model: "gpt-5.6-sol",
2029 },
2030 });
2031 composerDraftFileMocks.blockRead();
2032 composerDraftFileMocks.resetWrites();
2033
2034 ensureComposerDraftsLoaded();
2035 await Promise.resolve();
2036 // An edit lands before hydration finishes; its debounced write is gated
2037 // behind the blocked read.
2038 setComposerDraftText("new-task:environment-1:project-1", "New prompt");
2039
2040 const flush = flushComposerDrafts();
2041 await vi.advanceTimersByTimeAsync(200);
2042 // The flush must not have written the pre-hydration snapshot over disk.
2043 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
2044
2045 composerDraftFileMocks.releaseRead();
2046 await flush;
2047
2048 const written = JSON.parse(composerDraftFileMocks.getDocument());
2049 expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT);
2050 expect(written.drafts["new-task:environment-1:project-1"]).toEqual({
2051 text: "New prompt",
2052 attachments: [],
2053 });
2054 expect(written.stickyModelSelection).toEqual({
2055 instanceId: "codex",
2056 model: "gpt-5.6-sol",
2057 });
2058 });
2059
2060 it.each(["read", "decode"] as const)(
2061 "preserves saved drafts and attachment files when the draft %s fails",
2062 async (failure) => {
2063 vi.useFakeTimers();
2064 const file = {
2065 id: "saved-file",
2066 type: "file" as const,
2067 name: "report.pdf",
2068 mimeType: "application/pdf",
2069 sizeBytes: 42,
2070 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
2071 };
2072 composerDraftFileMocks.setDocument({
2073 schemaVersion: failure === "decode" ? 999 : 1,
2074 drafts: { "environment-1:saved": { text: "Saved draft", attachments: [file] } },
2075 });
2076 const original = composerDraftFileMocks.getDocument();
2077 if (failure === "read") {
2078 composerDraftFileMocks.setReadError(new Error("storage unavailable"));
2079 }
2080
2081 await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toMatchObject({
2082 operation: failure,
2083 });
2084 setComposerDraftText("environment-1:new", "Keep my new edits too");
2085 await expect(flushComposerDrafts()).rejects.toMatchObject({ operation: failure });
2086
2087 expect(composerDraftFileMocks.getDocument()).toBe(original);
2088 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
2089 },
2090 );
2091
2092 it("retries a failed debounced read on final flush without dropping saved drafts or new edits", async () => {
2093 vi.useFakeTimers();
2094 composerDraftFileMocks.setDocument({
2095 schemaVersion: 1,
2096 drafts: { "environment-1:saved": DRAFT },
2097 });
2098 const original = composerDraftFileMocks.getDocument();
2099 composerDraftFileMocks.setReadError(new Error("storage unavailable"));
2100 setComposerDraftText("environment-1:new", "New edits");
2101 await vi.advanceTimersByTimeAsync(200);
2102 expect(composerDraftFileMocks.getDocument()).toBe(original);
2103
2104 composerDraftFileMocks.setReadError(null);
2105 await flushComposerDrafts();
2106
2107 expect(JSON.parse(composerDraftFileMocks.getDocument()).drafts).toEqual({
2108 "environment-1:saved": DRAFT,
2109 "environment-1:new": { text: "New edits", attachments: [] },
2110 });
2111 });
2112
2113 it("serializes environment cleanup after an older queued write", async () => {
2114 vi.useFakeTimers();
2115 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
2116 composerDraftFileMocks.resetWrites();
2117 let releaseFirstWrite!: () => void;
2118 const firstWriteBarrier = new Promise<void>((resolve) => {
2119 releaseFirstWrite = resolve;
2120 });
2121 composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier);
2122 let writeCount = 0;
2123 const bothWritesCommitted = new Promise<void>((resolve) => {
2124 composerDraftFileMocks.setOnWrite(() => {
2125 writeCount += 1;
2126 if (writeCount === 2) {
2127 resolve();
2128 }
2129 });
2130 });
2131
2132 appAtomRegistry.set(composerDraftsAtom, {
2133 "environment-1:thread-1": DRAFT,
2134 "environment-2:thread-2": { text: "keep", attachments: [] },
2135 });
2136 setStickyComposerModelSelection({
2137 instanceId: ProviderInstanceId.make("codex"),
2138 model: "gpt-5.6-sol",
2139 });
2140 await vi.advanceTimersByTimeAsync(200);
2141
2142 const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1"));
2143 await Promise.resolve();
2144 // Cleanup write is queued behind the still-blocked debounced write.
2145 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
2146
2147 releaseFirstWrite();
2148 await clear;
2149 await bothWritesCommitted;
2150
2151 expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({
2152 schemaVersion: 1,
2153 drafts: {
2154 "environment-2:thread-2": { text: "keep", attachments: [] },
2155 },
2156 stickyModelSelection: {
2157 instanceId: "codex",
2158 model: "gpt-5.6-sol",
2159 },
2160 });
2161 });
2162
2163 it("clears sent content without clearing the selected model or workspace", () => {
2164 const draftKey = "environment-1:thread-1";
2165 const draft: ComposerDraft = {
2166 text: "send this",
2167 attachments: [],
2168 importedShareIds: ["share-1"],
2169 modelSelection: {
2170 instanceId: ProviderInstanceId.make("codex"),
2171 model: "gpt-5.4",
2172 options: [{ id: "reasoningEffort", value: "xhigh" }],
2173 },
2174 workspaceSelection: {
2175 mode: "worktree",
2176 branch: "main",
2177 worktreePath: null,
2178 },
2179 };
2180
2181 expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({
2182 [draftKey]: {
2183 modelSelection: draft.modelSelection,
2184 workspaceSelection: draft.workspaceSelection,
2185 text: "",
2186 attachments: [],
2187 },
2188 });
2189 });
2190
2191 it("drops draft-local model and workspace selections after sending a new task", () => {
2192 const draftKey = "new-task:environment-1:project-1";
2193 const draft: ComposerDraft = {
2194 text: "send this",
2195 attachments: [],
2196 modelSelection: {
2197 instanceId: ProviderInstanceId.make("codex"),
2198 model: "gpt-5.4",
2199 },
2200 workspaceSelection: {
2201 mode: "worktree",
2202 branch: "main",
2203 worktreePath: null,
2204 startFromOrigin: false,
2205 },
2206 };
2207
2208 expect(
2209 clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {
2210 clearModelSelection: true,
2211 clearWorkspaceSelection: true,
2212 }),
2213 ).toEqual({});
2214 });
2215
2216 it("reads the latest selector state synchronously for send", () => {
2217 const draftKey = "environment-1:thread-1";
2218 const selectedDraft: ComposerDraft = {
2219 text: "send this",
2220 attachments: [],
2221 modelSelection: {
2222 instanceId: ProviderInstanceId.make("codex"),
2223 model: "gpt-5.4",
2224 options: [{ id: "reasoningEffort", value: "xhigh" }],
2225 },
2226 };
2227 appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft });
2228
2229 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
2230 });
2231
2232 it("drops another environment's upload stamp when a draft moves across machines", () => {
2233 const uploadedElsewhere: DraftComposerAttachment = {
2234 id: "image-1",
2235 type: "image",
2236 name: "screen.png",
2237 mimeType: "image/png",
2238 sizeBytes: 1,
2239 previewUri: "file:///drafts/screen.png",
2240 fileUri: "file:///drafts/screen.png",
2241 uploadedAttachmentId: "upload-1",
2242 uploadEnvironmentId: EnvironmentId.make("environment-1"),
2243 };
2244 const uploadedOnTarget: DraftComposerAttachment = {
2245 ...uploadedElsewhere,
2246 id: "image-2",
2247 uploadedAttachmentId: "upload-2",
2248 uploadEnvironmentId: EnvironmentId.make("environment-2"),
2249 };
2250 const key = createNewTaskDraft({
2251 environmentId: EnvironmentId.make("environment-1"),
2252 projectId: ProjectId.make("project-1"),
2253 });
2254 appAtomRegistry.set(composerDraftsAtom, {
2255 ...appAtomRegistry.get(composerDraftsAtom),
2256 [key]: {
2257 ...getComposerDraftSnapshot(key),
2258 text: "Ship it",
2259 attachments: [uploadedElsewhere, uploadedOnTarget],
2260 },
2261 });
2262
2263 retargetNewTaskDraft(key, {
2264 environmentId: EnvironmentId.make("environment-2"),
2265 projectId: ProjectId.make("project-2"),
2266 });
2267
2268 expect(getComposerDraftSnapshot(key).attachments).toEqual([
2269 {
2270 id: "image-1",
2271 type: "image",
2272 name: "screen.png",
2273 mimeType: "image/png",
2274 sizeBytes: 1,
2275 previewUri: "file:///drafts/screen.png",
2276 fileUri: "file:///drafts/screen.png",
2277 },
2278 uploadedOnTarget,
2279 ]);
2280 });
2281
2282 it("merges shared content into a project draft without duplicating retries", () => {
2283 const draftKey = "new-task:environment-1:project-1";
2284 const sharedAttachment = {
2285 id: "share-1:image:0",
2286 type: "image" as const,
2287 name: "Screenshot.png",
2288 mimeType: "image/png",
2289 sizeBytes: 3,
2290 dataUrl: "data:image/png;base64,YWJj",
2291 previewUri: "data:image/png;base64,YWJj",
2292 };
2293 const existing: Record<string, ComposerDraft> = {
2294 [draftKey]: { text: "Existing context", attachments: [] },
2295 };
2296 const content = {
2297 text: "Shared note",
2298 attachments: [sharedAttachment],
2299 sourceShareId: "share-1",
2300 };
2301
2302 const merged = mergeComposerDraftContentState(existing, draftKey, content);
2303 expect(merged[draftKey]).toMatchObject({
2304 text: "Existing context\n\nShared note",
2305 attachments: [sharedAttachment],
2306 importedShareIds: ["share-1"],
2307 });
2308 expect(mergeComposerDraftContentState(merged, draftKey, content)).toBe(merged);
2309
2310 const edited = {
2311 ...merged,
2312 [draftKey]: { ...merged[draftKey]!, text: "User edited the imported context" },
2313 };
2314 expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited);
2315 });
2316
2317 it("preserves existing images when shared content exceeds the draft attachment limit", () => {
2318 const draftKey = "new-task:environment-1:project-1";
2319 const image = (id: string) => ({
2320 id,
2321 type: "image" as const,
2322 name: `${id}.png`,
2323 mimeType: "image/png",
2324 sizeBytes: 3,
2325 dataUrl: "data:image/png;base64,YWJj",
2326 previewUri: "data:image/png;base64,YWJj",
2327 });
2328 const existingImage = image("existing");
2329 const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`));
2330
2331 const merged = mergeComposerDraftContentState(
2332 { [draftKey]: { text: "", attachments: [existingImage] } },
2333 draftKey,
2334 { text: "", attachments: sharedImages },
2335 );
2336
2337 expect(merged[draftKey]?.attachments).toHaveLength(100);
2338 expect(merged[draftKey]?.attachments[0]).toEqual(existingImage);
2339 expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98");
2340 });
2341
2342 it("restores the exact draft captured before an interrupted share import", () => {
2343 const draftKey = "new-task:environment-1:project-1";
2344 const beforeImport: ComposerDraft = {
2345 text: "Existing context",
2346 attachments: [],
2347 runtimeMode: "approval-required",
2348 };
2349 const imported: ComposerDraft = {
2350 ...beforeImport,
2351 text: "Existing context\n\nShared note",
2352 importedShareIds: ["share-1"],
2353 };
2354
2355 expect(
2356 restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, beforeImport),
2357 ).toEqual({ [draftKey]: beforeImport });
2358 expect(
2359 restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, {
2360 text: "",
2361 attachments: [],
2362 }),
2363 ).toEqual({});
2364 });
2365
2366 it("removes only drafts owned by the selected environment", () => {
2367 const environmentId = EnvironmentId.make("environment-cloud");
2368 const retainedEnvironmentId = EnvironmentId.make("environment-local");
2369
2370 const cloudDraft: ComposerDraft = {
2371 ...DRAFT,
2372 project: {
2373 environmentId,
2374 projectId: ProjectId.make("project-cloud"),
2375 createdAt: "2026-09-05T00:00:00.000Z",
2376 },
2377 };
2378 const localDraft: ComposerDraft = {
2379 ...DRAFT,
2380 project: {
2381 environmentId: retainedEnvironmentId,
2382 projectId: ProjectId.make("project-local"),
2383 createdAt: "2026-09-05T00:00:00.000Z",
2384 },
2385 };
2386
2387 expect(
2388 removeComposerDraftsForEnvironment(
2389 {
2390 [`${environmentId}:thread-cloud`]: DRAFT,
2391 "new-task:cloud-draft": cloudDraft,
2392 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
2393 "new-task:local-draft": localDraft,
2394 },
2395 environmentId,
2396 ),
2397 ).toEqual({
2398 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
2399 "new-task:local-draft": localDraft,
2400 });
2401 });
2402
2403 it("lands a still-debounced draft write when flushed", async () => {
2404 const draftKey = "environment-1:thread-1";
2405 setComposerDraftText(draftKey, "typed right before the restart");
2406
2407 await flushComposerDrafts();
2408
2409 expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({
2410 drafts: { [draftKey]: { text: "typed right before the restart" } },
2411 });
2412 });
2413
2414 it("propagates a flush write failure instead of resolving as saved", async () => {
2415 const draftKey = "environment-1:thread-1";
2416 setComposerDraftText(draftKey, "unsaved");
2417 composerDraftFileMocks.setWriteError(new Error("storage unavailable"));
2418
2419 try {
2420 await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError);
2421 } finally {
2422 composerDraftFileMocks.setWriteError(null);
2423 }
2424 });
2425
2426 it("restores the pre-merge snapshot when the draft is untouched since the merge", () => {
2427 const draftKey = "environment-1:thread-1";
2428 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
2429 const merged: ComposerDraft = {
2430 text: "typed before\n\nqueued text",
2431 attachments: [],
2432 runtimeMode: "approval-required",
2433 };
2434
2435 expect(undoComposerDraftMergeState({ [draftKey]: merged }, draftKey, snapshot, merged)).toEqual(
2436 { [draftKey]: snapshot },
2437 );
2438 expect(
2439 undoComposerDraftMergeState(
2440 { [draftKey]: merged },
2441 draftKey,
2442 { text: "", attachments: [] },
2443 merged,
2444 ),
2445 ).toEqual({});
2446 });
2447
2448 it("persists an async merge rollback with the sticky model selection", async () => {
2449 const draftKey = "environment-1:thread-1";
2450 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
2451 const merged: ComposerDraft = {
2452 text: "typed before\n\nqueued text",
2453 attachments: [],
2454 };
2455 composerDraftFileMocks.setDocument({
2456 schemaVersion: 1,
2457 drafts: { [draftKey]: merged },
2458 stickyModelSelection: {
2459 instanceId: "codex",
2460 model: "gpt-5.6-sol",
2461 },
2462 });
2463
2464 await undoComposerDraftMerge(draftKey, snapshot, merged);
2465
2466 expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({
2467 schemaVersion: 1,
2468 drafts: { [draftKey]: snapshot },
2469 stickyModelSelection: {
2470 instanceId: "codex",
2471 model: "gpt-5.6-sol",
2472 },
2473 });
2474 });
2475
2476 it("returns merge-written settings to the snapshot but keeps user-edited ones", () => {
2477 const draftKey = "environment-1:thread-1";
2478 const snapshot: ComposerDraft = {
2479 text: "typed before",
2480 attachments: [],
2481 runtimeMode: "approval-required",
2482 interactionMode: "default",
2483 };
2484 const merged: ComposerDraft = {
2485 text: "typed before\n\nqueued text",
2486 attachments: [],
2487 runtimeMode: "full-access",
2488 interactionMode: "default",
2489 };
2490 // The user edited the text (forcing the partial undo) and also switched
2491 // interaction mode, but never touched the merge-written runtime mode.
2492 const edited: ComposerDraft = {
2493 text: "typed EDITED before\n\nqueued text",
2494 attachments: [],
2495 runtimeMode: "full-access",
2496 interactionMode: "plan",
2497 };
2498
2499 expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual(
2500 {
2501 [draftKey]: {
2502 text: "typed EDITED before",
2503 attachments: [],
2504 runtimeMode: "approval-required",
2505 interactionMode: "plan",
2506 },
2507 },
2508 );
2509 });
2510
2511 it("takes out only what the merge inserted when the user edited during it", () => {
2512 const draftKey = "environment-1:thread-1";
2513 const keptAttachment = {
2514 id: "kept",
2515 type: "file" as const,
2516 name: "kept.pdf",
2517 mimeType: "application/pdf",
2518 sizeBytes: 1,
2519 fileUri: "file:///documents/t3-composer-attachments/kept.pdf",
2520 };
2521 const insertedAttachment = {
2522 id: "inserted",
2523 type: "file" as const,
2524 name: "inserted.pdf",
2525 mimeType: "application/pdf",
2526 sizeBytes: 1,
2527 fileUri: "file:///documents/t3-composer-attachments/inserted.pdf",
2528 };
2529 const userAttachment = { ...keptAttachment, id: "user-added" };
2530 const snapshot: ComposerDraft = { text: "typed before", attachments: [keptAttachment] };
2531 const merged: ComposerDraft = {
2532 text: "typed before\n\nqueued text",
2533 attachments: [keptAttachment, insertedAttachment],
2534 };
2535 // The user rewrote the leading text and attached a file mid-recovery.
2536 const edited: ComposerDraft = {
2537 text: "typed EDITED before\n\nqueued text",
2538 attachments: [keptAttachment, insertedAttachment, userAttachment],
2539 };
2540
2541 expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual(
2542 {
2543 [draftKey]: {
2544 text: "typed EDITED before",
2545 attachments: [keptAttachment, userAttachment],
2546 },
2547 },
2548 );
2549
2550 // Edits that broke the merged suffix keep their text untouched; only the
2551 // inserted attachments still come out.
2552 const rewritten: ComposerDraft = {
2553 text: "totally rewritten",
2554 attachments: [insertedAttachment],
2555 };
2556 expect(
2557 undoComposerDraftMergeState({ [draftKey]: rewritten }, draftKey, snapshot, merged),
2558 ).toEqual({
2559 [draftKey]: { text: "totally rewritten", attachments: [] },
2560 });
2561 });
2562
2563 it("keeps text appended after a merge when rolling it back", () => {
2564 const draftKey = "environment-1:thread-1";
2565 const snapshot: ComposerDraft = { text: "typed before", attachments: [] };
2566 const content = { text: "queued text", attachments: [] };
2567 const merged = mergeComposerDraftContentState({ [draftKey]: snapshot }, draftKey, content)[
2568 draftKey
2569 ]!;
2570 const edited: ComposerDraft = {
2571 ...merged,
2572 text: `${merged.text}\n\nuser follow-up`,
2573 };
2574
2575 const rolledBack = undoComposerDraftMergeState(
2576 { [draftKey]: edited },
2577 draftKey,
2578 snapshot,
2579 merged,
2580 );
2581
2582 expect(rolledBack[draftKey]?.text).toBe("typed before\n\nuser follow-up");
2583 const retried = mergeComposerDraftContentState(rolledBack, draftKey, content);
2584 expect(retried[draftKey]?.text.match(/queued text/g)).toHaveLength(1);
2585 });
2586
2587 it("spares a file re-owned between the sweep's scan and its deletion", async () => {
2588 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
2589 onTestFinished(() => outboxLoad.mockRestore());
2590 const fileFor = (id: string) => ({
2591 id,
2592 type: "file" as const,
2593 name: `${id}.pdf`,
2594 mimeType: "application/pdf",
2595 sizeBytes: 42,
2596 fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`,
2597 });
2598 const first = fileFor("file-first");
2599 const reowned = fileFor("file-reowned");
2600 // A restore re-owns the second file while the first deletion is in
2601 // flight, after the sweep already decided both were unused.
2602 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
2603 appAtomRegistry.set(composerDraftsAtom, {
2604 "environment-1:thread-1": { text: "restored", attachments: [reowned] },
2605 });
2606 });
2607
2608 await releaseUnusedComposerAttachmentFiles([first, reowned]);
2609
2610 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]);
2611 });
2612
2613 // These final tests use fresh module instances for independent hydration.
2614 it("keeps attachment files and uploads after a recovered message leaves an incomplete outbox", async () => {
2615 vi.resetModules();
2616 const drafts = await import("./use-composer-drafts");
2617 const { threadOutboxManager: manager } = await import("./thread-outbox");
2618 const { expoThreadOutboxStorage: storage, ThreadOutboxStorageError } =
2619 await import("./thread-outbox-storage");
2620 const { removeThreadOutboxMessage } = await import("./thread-outbox-removal");
2621 const { appAtomRegistry: registry } = await import("./atom-registry");
2622 onTestFinished(() => registry.dispose());
2623 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
2624 onTestFinished(() => warning.mockRestore());
2625 const file = {
2626 id: "file-partial-outbox",
2627 type: "file" as const,
2628 name: "shared.pdf",
2629 mimeType: "application/pdf",
2630 sizeBytes: 42,
2631 fileUri: "file:///documents/t3-composer-attachments/shared.pdf",
2632 uploadEnvironmentId: EnvironmentId.make("environment-1"),
2633 uploadedAttachmentId: "pending-partial-outbox",
2634 };
2635 const readable = {
2636 environmentId: EnvironmentId.make("environment-1"),
2637 threadId: ThreadId.make("thread-1"),
2638 messageId: MessageId.make("message-readable"),
2639 commandId: CommandId.make("command-readable"),
2640 text: "Send the readable message",
2641 attachments: [file],
2642 createdAt: "2026-09-04T00:00:00.000Z",
2643 };
2644 const saved = new Map([[readable.messageId, readable]]);
2645 let unreadable = true;
2646 const load = vi.spyOn(storage, "load").mockImplementation(async () => ({
2647 messages: [...saved.values()],
2648 errors: unreadable
2649 ? [
2650 new ThreadOutboxStorageError({
2651 operation: "read-message",
2652 environmentId: null,
2653 threadId: null,
2654 messageId: null,
2655 fileName: "unreadable.json",
2656 cause: new Error("unreadable record"),
2657 }),
2658 ]
2659 : [],
2660 }));
2661 const remove = vi.spyOn(storage, "remove").mockImplementation(async (message) => {
2662 saved.delete(message.messageId);
2663 });
2664 onTestFinished(() => {
2665 load.mockRestore();
2666 remove.mockRestore();
2667 });
2668
2669 await expect(manager.load()).resolves.toBe(false);
2670 await expect(manager.confirmQueued(readable)).resolves.toBe(true);
2671 await expect(removeThreadOutboxMessage(readable)).resolves.toBe(true);
2672 await drafts.releaseUnusedComposerAttachmentFiles([file]);
2673 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
2674 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
2675 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
2676
2677 const recovered = {
2678 ...readable,
2679 environmentId: EnvironmentId.make("environment-2"),
2680 messageId: MessageId.make("message-recovered"),
2681 commandId: CommandId.make("command-recovered"),
2682 };
2683 saved.set(recovered.messageId, recovered);
2684 unreadable = false;
2685 await expect(manager.load()).resolves.toBe(true);
2686 await drafts.releaseUnusedComposerAttachmentFiles([file]);
2687 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
2688 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
2689
2690 await removeThreadOutboxMessage(recovered);
2691 await drafts.releaseUnusedComposerAttachmentFiles([file]);
2692 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
2693 expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(
2694 file.uploadEnvironmentId,
2695 [file.uploadedAttachmentId],
2696 );
2697 });
2698
2699 it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => {
2700 const file = {
2701 id: "file-cold-start",
2702 type: "file" as const,
2703 name: "report.pdf",
2704 mimeType: "application/pdf",
2705 sizeBytes: 42,
2706 fileUri: "file:///documents/t3-composer-attachments/report.pdf",
2707 };
2708 composerDraftFileMocks.setDocument({
2709 schemaVersion: 1,
2710 drafts: {
2711 "environment-1:thread-1": { text: "Persisted draft", attachments: [file] },
2712 },
2713 });
2714 vi.resetModules();
2715 const fresh = await import("./use-composer-drafts");
2716 const freshRegistry = (await import("./atom-registry")).appAtomRegistry;
2717
2718 await fresh.releaseUnusedComposerAttachmentFiles([file]);
2719
2720 expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({
2721 "environment-1:thread-1": {
2722 text: "Persisted draft [report.pdf](t3-context://v1/file/file-cold-start) ",
2723 attachments: [file],
2724 context: {
2725 version: 1,
2726 records: [
2727 {
2728 version: 1,
2729 contextId: file.id,
2730 kind: "file",
2731 label: file.name,
2732 attachmentId: file.id,
2733 name: file.name,
2734 mimeType: file.mimeType,
2735 sizeBytes: file.sizeBytes,
2736 },
2737 ],
2738 },
2739 },
2740 });
2741 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
2742 });
2743});