import { afterEach, describe, expect, it } from "@effect/vitest";import { CommandId, EnvironmentId, MessageId, ProviderInstanceId, ThreadId,} from "@t3tools/contracts";import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = ""; let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); let nextWriteBarrier: Promise<void> | null = null; let onWrite: (() => void) | null = null; const writes: string[] = []; return { blockRead() { readBarrier = new Promise<void>((resolve) => { releaseRead = resolve; }); }, releaseRead() { releaseRead?.(); releaseRead = null; }, getDocument() { return document; }, setDocument(value: unknown) { document = JSON.stringify(value); }, setWriteError(error: Error | null) { writeError = error; }, setNextWriteBarrier(barrier: Promise<void> | null) { nextWriteBarrier = barrier; }, setOnWrite(callback: (() => void) | null) { onWrite = callback; }, getWrites(): ReadonlyArray<string> { return writes; }, resetWrites() { writes.length = 0; }, Directory: class { create() {} }, File: class { exists = true; parentDirectory = null; create() {} moveSync() {} async text() { await readBarrier; return document; } write(value: string) { if (writeError) { throw writeError; } if (nextWriteBarrier) { const barrier = nextWriteBarrier; nextWriteBarrier = null; return barrier.then(() => { document = value; writes.push(value); onWrite?.(); }); } document = value; writes.push(value); onWrite?.(); } }, };}); const composerAttachmentCleanupMocks = vi.hoisted(() => ({ remove: vi.fn(async () => undefined), releaseUploads: vi.fn(async () => undefined),})); const incomingShareStorageMocks = vi.hoisted(() => ({ load: vi.fn<typeof import("../features/sharing/incoming-share-storage").loadIncomingShareDrafts>( async () => [], ),})); vi.mock("expo-file-system", () => ({ Directory: composerDraftFileMocks.Directory, File: composerDraftFileMocks.File, Paths: { document: "/documents" },})); vi.mock("../lib/composerImages", () => ({ removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,})); vi.mock("../lib/attachmentUpload", () => ({ releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,})); vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load,})); import { appAtomRegistry } from "./atom-registry";import { threadOutboxManager } from "./thread-outbox";import { appendComposerDraftAttachments, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, setComposerDraftText, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState,} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [],}; afterEach(() => { vi.useRealTimers(); resetComposerDraftsLoadState(); composerDraftFileMocks.setDocument(""); composerDraftFileMocks.setWriteError(null); composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined); incomingShareStorageMocks.load.mockReset(); incomingShareStorageMocks.load.mockResolvedValue([]);}); describe("mobile composer drafts", () => { // Hydration is one-shot per module instance and the attachment sweep now // triggers it too, so this test must observe it before any sweep test runs. it("waits for persisted drafts before copying content between projects", async () => { const sourceKey = "new-task:environment-1:project-1"; const targetKey = "new-task:environment-1:project-2"; const unrelatedKey = "environment-1:thread-1"; const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: { [targetKey]: target, [unrelatedKey]: unrelated, }, }); composerDraftFileMocks.blockRead(); appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); composerDraftFileMocks.releaseRead(); await copy; expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source, [targetKey]: target, [unrelatedKey]: unrelated, }); }); it("hydrates generic file attachments from their saved local paths", () => { const file = { id: "file-1", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/report.pdf", }; expect( decodePersistedComposerDrafts({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, }), ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); }); it("releases videos rejected by the live draft limit and keeps accepted files", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const cleanup = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { cleanup.resolve(); }); const makeAttachment = (id: string) => ({ id, type: "file" as const, name: `${id}.mov`, mimeType: "video/quicktime", sizeBytes: 42, fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, }); const draftKey = "new-task:environment-1:project-cap"; const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "send this", attachments: existing }, }); const rejected = appendComposerDraftAttachments(draftKey, [ makeAttachment("incoming-1"), makeAttachment("incoming-2"), ]); expect(rejected).toBe(1); const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; expect(draft?.attachments).toHaveLength(8); expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( makeAttachment("incoming-2").fileUri, ); // Restore paths bypass the cap so a failed send never drops its files. const overflowRejected = appendComposerDraftAttachments( draftKey, [makeAttachment("restored-1")], { allowOverflow: true }, ); expect(overflowRejected).toBe(0); expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); }); it("keeps shared attachment files until every draft releases them", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-1", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/report.pdf", }; appAtomRegistry.set(composerDraftsAtom, { source: { text: "First draft", attachments: [file] }, copied: { text: "Second draft", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(composerDraftsAtom, { copied: { text: "Second draft", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(composerDraftsAtom, {}); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); }); it("keeps a failed-send draft's pending upload for retry", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-failed-send", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf", uploadedAttachmentId: "pending-failed-send", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "Retry this send", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); it("keeps a removed file until both playback and a share copy finish", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; const file = { id: "file-preview", type: "file" as const, name: "recording.mp4", mimeType: "video/mp4", sizeBytes: 42, fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, }; const currentFile = { ...file, fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, }; const releasePlayback = retainComposerAttachmentFileForPreview(file); const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); onTestFinished(releasePlayback); onTestFinished(releaseShareCopy); await releaseUnusedComposerAttachmentFiles([currentFile]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); releasePlayback(); releasePlayback(); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); const deleted = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { deleted.resolve(); return undefined; }); releaseShareCopy(); await deleted.promise; expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); }); it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-opening-preview", type: "file" as const, name: "recording.mp4", mimeType: "video/mp4", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/recording.mp4", }; const ownershipReadStarted = Promise.withResolvers<void>(); const ownershipRead = Promise.withResolvers<[]>(); incomingShareStorageMocks.load.mockImplementationOnce(() => { ownershipReadStarted.resolve(); return ownershipRead.promise; }); const cleanup = releaseUnusedComposerAttachmentFiles([file]); await ownershipReadStarted.promise; const release = retainComposerAttachmentFileForPreview(file); onTestFinished(release); ownershipRead.resolve([]); await cleanup; expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); const deleted = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { deleted.resolve(); return undefined; }); release(); await deleted.promise; expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); }); it("removes an unreferenced local file and its pending upload", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); const file = { id: "file-discarded", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/discarded.pdf", uploadedAttachmentId: "pending-discarded", uploadEnvironmentId: environmentId, }; await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ "pending-discarded", ]); }); it("keeps a pending upload referenced through another local file", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); const discarded = { id: "file-discarded-copy", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf", uploadedAttachmentId: "pending-shared", uploadEnvironmentId: environmentId, }; const retained = { ...discarded, id: "file-retained-copy", fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf", }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] }, }); await releaseUnusedComposerAttachmentFiles([discarded]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri); expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); it("completes local cleanup when pending upload deletion fails", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); onTestFinished(() => warning.mockRestore()); composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce( new Error("environment disconnected"), ); const file = { id: "file-delete-failed", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf", uploadedAttachmentId: "pending-delete-failed", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined(); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); expect(warning).toHaveBeenCalledWith( "[composer-attachments] could not remove pending upload", expect.objectContaining({ attachmentId: "pending-delete-failed" }), ); }); it("keeps local attachment files while an outbox message still needs them", async () => { const file = { id: "file-queued", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/report.pdf", }; appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { "environment-1:thread-1": [ { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1"), commandId: CommandId.make("command-1"), text: "Review the report", attachments: [file], createdAt: "2026-08-24T12:00:00.000Z", }, ], });Lines 1–500 of 1403. Load more
import { afterEach, describe, expect, it } from "@effect/vitest"; import { CommandId, EnvironmentId, MessageId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = ""; let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); let nextWriteBarrier: Promise<void> | null = null; let onWrite: (() => void) | null = null; const writes: string[] = []; return { blockRead() { readBarrier = new Promise<void>((resolve) => { releaseRead = resolve; }); }, releaseRead() { releaseRead?.(); releaseRead = null; }, getDocument() { return document; }, setDocument(value: unknown) { document = JSON.stringify(value); }, setWriteError(error: Error | null) { writeError = error; }, setNextWriteBarrier(barrier: Promise<void> | null) { nextWriteBarrier = barrier; }, setOnWrite(callback: (() => void) | null) { onWrite = callback; }, getWrites(): ReadonlyArray<string> { return writes; }, resetWrites() { writes.length = 0; }, Directory: class { create() {} }, File: class { exists = true; parentDirectory = null; create() {} moveSync() {} async text() { await readBarrier; return document; } write(value: string) { if (writeError) { throw writeError; } if (nextWriteBarrier) { const barrier = nextWriteBarrier; nextWriteBarrier = null; return barrier.then(() => { document = value; writes.push(value); onWrite?.(); }); } document = value; writes.push(value); onWrite?.(); } }, }; }); const composerAttachmentCleanupMocks = vi.hoisted(() => ({ remove: vi.fn(async () => undefined), releaseUploads: vi.fn(async () => undefined), })); const incomingShareStorageMocks = vi.hoisted(() => ({ load: vi.fn<typeof import("../features/sharing/incoming-share-storage").loadIncomingShareDrafts>( async () => [], ), })); vi.mock("expo-file-system", () => ({ Directory: composerDraftFileMocks.Directory, File: composerDraftFileMocks.File, Paths: { document: "/documents" }, })); vi.mock("../lib/composerImages", () => ({ removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, })); vi.mock("../lib/attachmentUpload", () => ({ releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, })); vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, setComposerDraftText, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; afterEach(() => { vi.useRealTimers(); resetComposerDraftsLoadState(); composerDraftFileMocks.setDocument(""); composerDraftFileMocks.setWriteError(null); composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined); incomingShareStorageMocks.load.mockReset(); incomingShareStorageMocks.load.mockResolvedValue([]); }); describe("mobile composer drafts", () => { // Hydration is one-shot per module instance and the attachment sweep now // triggers it too, so this test must observe it before any sweep test runs. it("waits for persisted drafts before copying content between projects", async () => { const sourceKey = "new-task:environment-1:project-1"; const targetKey = "new-task:environment-1:project-2"; const unrelatedKey = "environment-1:thread-1"; const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: { [targetKey]: target, [unrelatedKey]: unrelated, }, }); composerDraftFileMocks.blockRead(); appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); composerDraftFileMocks.releaseRead(); await copy; expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source, [targetKey]: target, [unrelatedKey]: unrelated, }); }); it("hydrates generic file attachments from their saved local paths", () => { const file = { id: "file-1", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/report.pdf", }; expect( decodePersistedComposerDrafts({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, }), ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); }); it("releases videos rejected by the live draft limit and keeps accepted files", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const cleanup = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { cleanup.resolve(); }); const makeAttachment = (id: string) => ({ id, type: "file" as const, name: `${id}.mov`, mimeType: "video/quicktime", sizeBytes: 42, fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, }); const draftKey = "new-task:environment-1:project-cap"; const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "send this", attachments: existing }, }); const rejected = appendComposerDraftAttachments(draftKey, [ makeAttachment("incoming-1"), makeAttachment("incoming-2"), ]); expect(rejected).toBe(1); const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; expect(draft?.attachments).toHaveLength(8); expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( makeAttachment("incoming-2").fileUri, ); // Restore paths bypass the cap so a failed send never drops its files. const overflowRejected = appendComposerDraftAttachments( draftKey, [makeAttachment("restored-1")], { allowOverflow: true }, ); expect(overflowRejected).toBe(0); expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); }); it("keeps shared attachment files until every draft releases them", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-1", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/report.pdf", }; appAtomRegistry.set(composerDraftsAtom, { source: { text: "First draft", attachments: [file] }, copied: { text: "Second draft", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(composerDraftsAtom, { copied: { text: "Second draft", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(composerDraftsAtom, {}); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); }); it("keeps a failed-send draft's pending upload for retry", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-failed-send", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf", uploadedAttachmentId: "pending-failed-send", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "Retry this send", attachments: [file] }, }); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); it("keeps a removed file until both playback and a share copy finish", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; const file = { id: "file-preview", type: "file" as const, name: "recording.mp4", mimeType: "video/mp4", sizeBytes: 42, fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, }; const currentFile = { ...file, fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, }; const releasePlayback = retainComposerAttachmentFileForPreview(file); const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); onTestFinished(releasePlayback); onTestFinished(releaseShareCopy); await releaseUnusedComposerAttachmentFiles([currentFile]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); releasePlayback(); releasePlayback(); await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); const deleted = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { deleted.resolve(); return undefined; }); releaseShareCopy(); await deleted.promise; expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); }); it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const file = { id: "file-opening-preview", type: "file" as const, name: "recording.mp4", mimeType: "video/mp4", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/recording.mp4", }; const ownershipReadStarted = Promise.withResolvers<void>(); const ownershipRead = Promise.withResolvers<[]>(); incomingShareStorageMocks.load.mockImplementationOnce(() => { ownershipReadStarted.resolve(); return ownershipRead.promise; }); const cleanup = releaseUnusedComposerAttachmentFiles([file]); await ownershipReadStarted.promise; const release = retainComposerAttachmentFileForPreview(file); onTestFinished(release); ownershipRead.resolve([]); await cleanup; expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); const deleted = Promise.withResolvers<void>(); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { deleted.resolve(); return undefined; }); release(); await deleted.promise; expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); }); it("removes an unreferenced local file and its pending upload", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); const file = { id: "file-discarded", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/discarded.pdf", uploadedAttachmentId: "pending-discarded", uploadEnvironmentId: environmentId, }; await releaseUnusedComposerAttachmentFiles([file]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ "pending-discarded", ]); }); it("keeps a pending upload referenced through another local file", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); const discarded = { id: "file-discarded-copy", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf", uploadedAttachmentId: "pending-shared", uploadEnvironmentId: environmentId, }; const retained = { ...discarded, id: "file-retained-copy", fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf", }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] }, }); await releaseUnusedComposerAttachmentFiles([discarded]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri); expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); it("completes local cleanup when pending upload deletion fails", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); onTestFinished(() => warning.mockRestore()); composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce( new Error("environment disconnected"), ); const file = { id: "file-delete-failed", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf", uploadedAttachmentId: "pending-delete-failed", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined(); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); expect(warning).toHaveBeenCalledWith( "[composer-attachments] could not remove pending upload", expect.objectContaining({ attachmentId: "pending-delete-failed" }), ); }); it("keeps local attachment files while an outbox message still needs them", async () => { const file = { id: "file-queued", type: "file" as const, name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, fileUri: "file:///documents/t3-composer-attachments/report.pdf", }; appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { "environment-1:thread-1": [ { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1"), commandId: CommandId.make("command-1"), text: "Review the report", attachments: [file], createdAt: "2026-08-24T12:00:00.000Z", }, ], });