Open History to see the last commit
1import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { DraftId, useComposerDraftStore } from "../../composerDraftStore";
5import { importComposerThreadAttachment } from "./composerThreadImport";
6
7function deferredFile() {
8 let resolve!: (file: File) => void;
9 let reject!: (error: Error) => void;
10 const promise = new Promise<File>((resolveFile, rejectFile) => {
11 resolve = resolveFile;
12 reject = rejectFile;
13 });
14 return { promise, resolve, reject };
15}
16
17describe("importComposerThreadAttachment", () => {
18 it("rejects the next drop before loading when the draft takes the final slot", async () => {
19 const targetKey = DraftId.make("thread-import-synchronous-admission");
20 const store = useComposerDraftStore.getState();
21 const pendingImports = new Map<string, number>();
22 const files = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => ({
23 type: "file" as const,
24 id: `existing-${index}`,
25 name: `existing-${index}.txt`,
26 mimeType: "text/plain",
27 sizeBytes: 1,
28 file: new File(["x"], `existing-${index}.txt`, { type: "text/plain" }),
29 }));
30 store.addFiles(targetKey, files);
31 const load = vi.fn(async () => new File(["transcript"], "thread.jsonl"));
32 const onLimitReached = vi.fn();
33 const input = {
34 targetKey,
35 pendingImports,
36 countReservedAttachments: () =>
37 (store.getComposerDraft(targetKey)?.files.length ?? 0) +
38 (pendingImports.get(targetKey) ?? 0),
39 load,
40 isActive: () => true,
41 attach: async (file: File) =>
42 store.addFiles(targetKey, [
43 {
44 type: "file",
45 id: "transcript",
46 name: file.name,
47 mimeType: file.type,
48 sizeBytes: file.size,
49 file,
50 },
51 ]).length > 0,
52 onLimitReached,
53 };
54
55 await importComposerThreadAttachment(input);
56 await importComposerThreadAttachment(input);
57
58 expect(load).toHaveBeenCalledTimes(1);
59 expect(onLimitReached).toHaveBeenCalledTimes(1);
60 expect(store.getComposerDraft(targetKey)?.files.at(-1)?.name).toBe("thread.jsonl");
61 expect(pendingImports.size).toBe(0);
62 store.clearComposerContent(targetKey);
63 });
64
65 it("keeps the second drop's slot reserved when the first drop finishes", async () => {
66 const pendingImports = new Map<string, number>();
67 const first = deferredFile();
68 const second = deferredFile();
69 const attached: string[] = [];
70 const countReservedAttachments = () =>
71 PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 + attached.length + (pendingImports.get("draft") ?? 0);
72 const attach = async (file: File) => {
73 if (countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) return false;
74 attached.push(file.name);
75 return true;
76 };
77 const input = {
78 targetKey: "draft",
79 pendingImports,
80 countReservedAttachments,
81 isActive: () => true,
82 attach,
83 onLimitReached: () => {
84 throw new Error("Both slots should be available");
85 },
86 };
87 const firstImport = importComposerThreadAttachment({ ...input, load: () => first.promise });
88 const secondImport = importComposerThreadAttachment({ ...input, load: () => second.promise });
89 first.resolve(new File(["first"], "first-thread.txt"));
90 await firstImport;
91 expect(await attach(new File(["unrelated"], "unrelated.txt"))).toBe(false);
92
93 second.resolve(new File(["second"], "second-thread.txt"));
94 await secondImport;
95 expect(attached).toEqual(["first-thread.txt", "second-thread.txt"]);
96 expect(pendingImports.size).toBe(0);
97 });
98
99 it.each(["failure", "inactive"])(
100 "releases the original draft's slot after %s",
101 async (outcome) => {
102 const pendingImports = new Map<string, number>();
103 const transcript = deferredFile();
104 const attach = vi.fn(async (_file: File) => true);
105 let activeTargetKey = "original-draft";
106 const input = {
107 targetKey: "original-draft",
108 pendingImports,
109 countReservedAttachments: () =>
110 PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 + (pendingImports.get("original-draft") ?? 0),
111 load: () => transcript.promise,
112 isActive: () => activeTargetKey === "original-draft",
113 attach,
114 onLimitReached: () => {
115 throw new Error("The slot should have been released");
116 },
117 };
118 const importing = importComposerThreadAttachment(input);
119 if (outcome === "inactive") activeTargetKey = "new-draft";
120 expect(pendingImports.get("new-draft") ?? 0).toBe(0);
121 if (outcome === "failure") {
122 const failed = expect(importing).rejects.toThrow("Failed to load");
123 transcript.reject(new Error("Failed to load"));
124 await failed;
125 } else {
126 transcript.resolve(new File(["transcript"], "stale-thread.txt"));
127 await importing;
128 }
129 expect(attach).not.toHaveBeenCalled();
130 expect(pendingImports.size).toBe(0);
131
132 await importComposerThreadAttachment({
133 ...input,
134 load: async () => new File(["transcript"], "retry-thread.txt"),
135 isActive: () => true,
136 });
137 expect(attach.mock.calls[0]?.[0]?.name).toBe("retry-thread.txt");
138 expect(pendingImports.size).toBe(0);
139 },
140 );
141});