import { afterEach, describe, expect, it } from "@effect/vitest";import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId,} from "@t3tools/contracts";import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); let readError: Error | null = null; 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[] = []; const readImage = vi.fn(async () => "YWJj"); return { readImage, blockRead() { readBarrier = new Promise<void>((resolve) => { releaseRead = resolve; }); }, releaseRead() { releaseRead?.(); releaseRead = null; }, getDocument() { return document; }, setDocument(value: unknown) { document = JSON.stringify(value); }, setReadError(error: Error | null) { readError = error; }, 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() {} list() { return []; } }, File: class { exists = true; parentDirectory = null; create() {} moveSync() {} async text() { await readBarrier; if (readError) throw readError; return document; } async base64() { return readImage(); } 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: { uri: "file:///documents" } },})); vi.mock("../lib/composerImages", async (importOriginal) => ({ ...(await importOriginal<typeof import("../lib/composerImages")>()), removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,})); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));vi.mock("./assets", () => ({ assetEnvironment: {} }));vi.mock("./attachments", () => ({ attachmentEnvironment: {} }));vi.mock("./session", () => ({ environmentSession: {} }));vi.mock("@t3tools/client-runtime/state/runtime", () => ({ createEnvironmentRpcCommand: () => Symbol("rpc-command"), executeAtomQuery: () => { throw new Error("Unexpected network query in the inline read test"); }, runAtomCommand: () => { throw new Error("Unexpected network command in the inline read test"); }, squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,})); vi.mock("../lib/attachmentUpload", () => ({ releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,})); vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load,})); import type { DraftComposerAttachment } from "../lib/composerImages";import { appAtomRegistry } from "./atom-registry";import { threadOutboxManager } from "./thread-outbox";import { appendComposerDraftAttachments, archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, composerCloudDraftsAtom, createNewTaskDraft, decodePersistedComposerState, ensureComposerDraftsLoaded, type ComposerDraft, findNewTaskDraftKeys, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, setComposerDraftText, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState,} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [],}; afterEach(() => { vi.useRealTimers(); resetComposerDraftsLoadState(); composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); composerDraftFileMocks.setReadError(null); composerDraftFileMocks.setWriteError(null); composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); composerDraftFileMocks.readImage.mockReset(); composerDraftFileMocks.readImage.mockResolvedValue("YWJj"); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); 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("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( decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, }).drafts, ).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("retains a referenced file-backed image and releases it once unreferenced", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const image = { id: "image-1", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, fileUri: "file:///documents/t3-composer-attachments/photo.png", previewUri: "file:///documents/t3-composer-attachments/photo.png", }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "look at this", attachments: [image] }, }); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); // A queued outbox message must keep the bytes alive after the draft clears. appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { "environment-1:thread-1": [ { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), messageId: MessageId.make("queued-image"), commandId: CommandId.make("command-image"), text: "look at this", attachments: [image], createdAt: "2026-08-31T12:00:00.000Z", }, ], }); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri); }); it("keeps an image through an inline read after its draft is removed", async () => { const { prepareTurnAttachments } = await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload"); const image = { id: "image-reading", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, fileUri: "file:///documents/t3-composer-attachments/photo.png", previewUri: "file:///documents/t3-composer-attachments/photo.png", }; const readStarted = Promise.withResolvers<void>(); const read = Promise.withResolvers<void>(); const removed = Promise.withResolvers<void>(); let imageExists = true; composerDraftFileMocks.readImage.mockImplementation(async () => { readStarted.resolve(); await read.promise; if (!imageExists) throw new Error("The image was deleted during its read"); return "YWJj"; }); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { imageExists = false; removed.resolve(); }); appendComposerDraftAttachments("environment-1:thread-1", [image]); await flushComposerDrafts(); const preparing = prepareTurnAttachments({ environmentId: EnvironmentId.make("environment-1"), attachments: [image], }); onTestFinished(async () => { read.resolve(); await preparing.catch(() => undefined); }); await readStarted.promise; clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true }); await releaseUnusedComposerAttachmentFiles([image]); expect(imageExists).toBe(true); read.resolve(); const prepared = await preparing; expect(prepared.status).toBe("ready"); if (prepared.status !== "ready") return; expect(prepared.attachments).toEqual([ { type: "image", name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", }, ]); await removed.promise; expect(imageExists).toBe(false); }); 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("retains offline image bytes and newer edits when an early upload finishes", async () => { const key = "environment-1:thread-1"; const image = { id: "photo", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", previewUri: "file:///photo.png", }; const second = { ...image, id: "second", name: "second.png" }; const uploaded = { ...image, uploadedAttachmentId: "pending-photo", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); appendComposerDraftAttachments(key, [image]); setComposerDraftText(key, "Edited while uploading"); appendComposerDraftAttachments(key, [second]); expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); await flushComposerDrafts(); appAtomRegistry.set(composerDraftsAtom, {}); resetComposerDraftsLoadState(); await waitForComposerDraftsLoaded(); expect(getComposerDraftSnapshot(key)).toMatchObject({ text: "Edited while uploading", attachments: [uploaded, second], }); expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); }); it("cleans up an unreferenced image upload even when there is no local file URI", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); await releaseUnusedComposerAttachmentFiles([ { id: "photo", type: "image", name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", previewUri: "file:///photo.png", uploadedAttachmentId: "pending-photo", uploadEnvironmentId: environmentId, }, ]); expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ "pending-photo", ]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); it.each(["file", "image"] as const)( "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account", async (type) => { const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => load.mockRestore()); await waitForComposerDraftsLoaded(); const environmentId = EnvironmentId.make("cloud-environment");Lines 1–500 of 1988. Load more
import { afterEach, describe, expect, it } from "@effect/vitest"; import { CommandId, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); let readError: Error | null = null; 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[] = []; const readImage = vi.fn(async () => "YWJj"); return { readImage, blockRead() { readBarrier = new Promise<void>((resolve) => { releaseRead = resolve; }); }, releaseRead() { releaseRead?.(); releaseRead = null; }, getDocument() { return document; }, setDocument(value: unknown) { document = JSON.stringify(value); }, setReadError(error: Error | null) { readError = error; }, 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() {} list() { return []; } }, File: class { exists = true; parentDirectory = null; create() {} moveSync() {} async text() { await readBarrier; if (readError) throw readError; return document; } async base64() { return readImage(); } 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: { uri: "file:///documents" } }, })); vi.mock("../lib/composerImages", async (importOriginal) => ({ ...(await importOriginal<typeof import("../lib/composerImages")>()), removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); vi.mock("./assets", () => ({ assetEnvironment: {} })); vi.mock("./attachments", () => ({ attachmentEnvironment: {} })); vi.mock("./session", () => ({ environmentSession: {} })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ createEnvironmentRpcCommand: () => Symbol("rpc-command"), executeAtomQuery: () => { throw new Error("Unexpected network query in the inline read test"); }, runAtomCommand: () => { throw new Error("Unexpected network command in the inline read test"); }, squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, })); vi.mock("../lib/attachmentUpload", () => ({ releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, })); vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, composerCloudDraftsAtom, createNewTaskDraft, decodePersistedComposerState, ensureComposerDraftsLoaded, type ComposerDraft, findNewTaskDraftKeys, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, setComposerDraftText, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; afterEach(() => { vi.useRealTimers(); resetComposerDraftsLoadState(); composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); composerDraftFileMocks.setReadError(null); composerDraftFileMocks.setWriteError(null); composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); composerDraftFileMocks.readImage.mockReset(); composerDraftFileMocks.readImage.mockResolvedValue("YWJj"); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); 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("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( decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, }).drafts, ).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("retains a referenced file-backed image and releases it once unreferenced", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const image = { id: "image-1", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, fileUri: "file:///documents/t3-composer-attachments/photo.png", previewUri: "file:///documents/t3-composer-attachments/photo.png", }; appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": { text: "look at this", attachments: [image] }, }); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); // A queued outbox message must keep the bytes alive after the draft clears. appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { "environment-1:thread-1": [ { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), messageId: MessageId.make("queued-image"), commandId: CommandId.make("command-image"), text: "look at this", attachments: [image], createdAt: "2026-08-31T12:00:00.000Z", }, ], }); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); await releaseUnusedComposerAttachmentFiles([image]); expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri); }); it("keeps an image through an inline read after its draft is removed", async () => { const { prepareTurnAttachments } = await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload"); const image = { id: "image-reading", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, fileUri: "file:///documents/t3-composer-attachments/photo.png", previewUri: "file:///documents/t3-composer-attachments/photo.png", }; const readStarted = Promise.withResolvers<void>(); const read = Promise.withResolvers<void>(); const removed = Promise.withResolvers<void>(); let imageExists = true; composerDraftFileMocks.readImage.mockImplementation(async () => { readStarted.resolve(); await read.promise; if (!imageExists) throw new Error("The image was deleted during its read"); return "YWJj"; }); composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { imageExists = false; removed.resolve(); }); appendComposerDraftAttachments("environment-1:thread-1", [image]); await flushComposerDrafts(); const preparing = prepareTurnAttachments({ environmentId: EnvironmentId.make("environment-1"), attachments: [image], }); onTestFinished(async () => { read.resolve(); await preparing.catch(() => undefined); }); await readStarted.promise; clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true }); await releaseUnusedComposerAttachmentFiles([image]); expect(imageExists).toBe(true); read.resolve(); const prepared = await preparing; expect(prepared.status).toBe("ready"); if (prepared.status !== "ready") return; expect(prepared.attachments).toEqual([ { type: "image", name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", }, ]); await removed.promise; expect(imageExists).toBe(false); }); 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("retains offline image bytes and newer edits when an early upload finishes", async () => { const key = "environment-1:thread-1"; const image = { id: "photo", type: "image" as const, name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", previewUri: "file:///photo.png", }; const second = { ...image, id: "second", name: "second.png" }; const uploaded = { ...image, uploadedAttachmentId: "pending-photo", uploadEnvironmentId: EnvironmentId.make("environment-1"), }; composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); appendComposerDraftAttachments(key, [image]); setComposerDraftText(key, "Edited while uploading"); appendComposerDraftAttachments(key, [second]); expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); await flushComposerDrafts(); appAtomRegistry.set(composerDraftsAtom, {}); resetComposerDraftsLoadState(); await waitForComposerDraftsLoaded(); expect(getComposerDraftSnapshot(key)).toMatchObject({ text: "Edited while uploading", attachments: [uploaded, second], }); expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); }); it("cleans up an unreferenced image upload even when there is no local file URI", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); const environmentId = EnvironmentId.make("environment-1"); await releaseUnusedComposerAttachmentFiles([ { id: "photo", type: "image", name: "photo.png", mimeType: "image/png", sizeBytes: 3, dataUrl: "data:image/png;base64,YWJj", previewUri: "file:///photo.png", uploadedAttachmentId: "pending-photo", uploadEnvironmentId: environmentId, }, ]); expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ "pending-photo", ]); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); it.each(["file", "image"] as const)( "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account", async (type) => { const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => load.mockRestore()); await waitForComposerDraftsLoaded(); const environmentId = EnvironmentId.make("cloud-environment");