Open History to see the last commit
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
4import { beforeEach, describe, expect, it } from "vite-plus/test";
5import {
6 DraftId,
7 useComposerDraftStore,
8 type ComposerFileAttachment,
9 type ComposerImageAttachment,
10} from "../../composerDraftStore";
11import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit";
12
13const environmentId = EnvironmentId.make("remote-environment");
14const threadTarget = scopeThreadRef(environmentId, ThreadId.make("thread:edit"));
15const editTarget = DraftId.make("queued-edit:test");
16const file: ComposerFileAttachment = {
17 type: "file",
18 id: "file:report",
19 name: "report.pdf",
20 mimeType: "application/pdf",
21 sizeBytes: 6,
22 file: new File(["report"], "report.pdf", { type: "application/pdf" }),
23};
24const image: ComposerImageAttachment = {
25 type: "image",
26 id: "image:screen",
27 name: "screen.png",
28 mimeType: "image/png",
29 sizeBytes: 5,
30 file: new File(["image"], "screen.png", { type: "image/png" }),
31 previewUrl: "blob:screen",
32};
33const uploadedFile = {
34 type: "file" as const,
35 id: "upload:report",
36 name: file.name,
37 mimeType: file.mimeType,
38 sizeBytes: file.sizeBytes,
39};
40
41describe("queued message file edits", () => {
42 beforeEach(() => {
43 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
44 });
45
46 it.each([
47 { images: [], label: "file-only" },
48 { images: [image], label: "mixed" },
49 ])("preserves a generic file in a $label save", async ({ images }) => {
50 const attachments = await prepareQueuedEditAttachments({
51 existingAttachments: [],
52 images,
53 files: [file],
54 uploadFiles: async () => [uploadedFile],
55 readImage: async () => "data:image/png;base64,aW1hZ2U=",
56 });
57 expect(attachments.at(-1)).toEqual(uploadedFile);
58 expect(attachments.length).toBe(images.length + 1);
59 if (images.length > 0)
60 expect(attachments[0]).toMatchObject({
61 type: "image",
62 dataUrl: "data:image/png;base64,aW1hZ2U=",
63 });
64 });
65
66 it("fails a save instead of dropping a file whose upload is missing", async () => {
67 await expect(
68 prepareQueuedEditAttachments({
69 existingAttachments: [],
70 images: [image],
71 files: [file],
72 uploadFiles: async () => [],
73 readImage: async () => "image",
74 }),
75 ).rejects.toThrow("Retry or remove");
76 });
77
78 it("keeps file-only edits when another client starts the queued run", () => {
79 const store = useComposerDraftStore.getState();
80 store.setPrompt(editTarget, "Original message");
81 store.addFiles(editTarget, [file]);
82 expect(
83 recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }),
84 ).toBe("kept");
85 expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message");
86 expect(store.getComposerDraft(threadTarget)?.files).toEqual([file]);
87 expect(store.getComposerDraft(editTarget)).toBeNull();
88 });
89
90 it("removes a kept edit draft that held a thread mention", () => {
91 const store = useComposerDraftStore.getState();
92 store.setPrompt(editTarget, "Edited message");
93 store.setThreadContexts(editTarget, [
94 threadContextRecord(scopeThreadRef(environmentId, ThreadId.make("thread:other")), "Other"),
95 ]);
96 expect(
97 recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }),
98 ).toBe("kept");
99 expect(store.getComposerDraft(editTarget)).toBeNull();
100 });
101
102 it("reports a removed queued attachment as an edit when the run leaves", () => {
103 const store = useComposerDraftStore.getState();
104 store.setPrompt(editTarget, "Original message");
105 expect(
106 recoverQueuedMessageEdit({
107 editTarget,
108 threadTarget,
109 originalText: "Original message",
110 removedExistingAttachment: true,
111 }),
112 ).toBe("kept");
113 expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message");
114 store.clearComposerContent(threadTarget);
115 // An attachment-only message leaves nothing to put back in the composer.
116 expect(
117 recoverQueuedMessageEdit({
118 editTarget,
119 threadTarget,
120 originalText: "",
121 removedExistingAttachment: true,
122 }),
123 ).toBe("discarded");
124 expect(store.getComposerDraft(threadTarget)).toBeNull();
125 });
126
127 it("does not overwrite a separate draft when the queued run leaves", () => {
128 const store = useComposerDraftStore.getState();
129 store.setPrompt(threadTarget, "Separate draft");
130 store.addFiles(editTarget, [file]);
131 expect(recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "" })).toBe(
132 "discarded",
133 );
134 expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft");
135 expect(store.getComposerDraft(editTarget)).toBeNull();
136 });
137});