1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 attachmentFileExtension,
10 chatAttachmentStableKey,
11 createAttachmentId,
12 createDeterministicAttachmentId,
13 createPendingAttachmentId,
14 parseAttachmentUuid,
15 parseAttachmentFileExtension,
16 planAttachmentClaim,
17 parseThreadSegmentFromAttachmentId,
18 resolveAttachmentPathById,
19 sweepStalePendingAttachments,
20 threadHtmlRenderAttachmentIds,
21} from "./attachmentStore.ts";
22
23describe("attachmentStore", () => {
24 it("derives stable attachment ids for idempotent message retries", () => {
25 const first = createDeterministicAttachmentId("thread-1", "message-1:0");
26 const retry = createDeterministicAttachmentId("thread-1", "message-1:0");
27 const next = createDeterministicAttachmentId("thread-1", "message-1:1");
28
29 expect(first).toBe(retry);
30 expect(next).not.toBe(first);
31 expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");
32 });
33
34 it("gives a queued edit's new image its own id instead of an earlier image's", () => {
35 const imageA = new Uint8Array([1, 2, 3]);
36 const imageB = new Uint8Array([4, 5, 6]);
37 const sent = createDeterministicAttachmentId(
38 "thread-1",
39 chatAttachmentStableKey("message-1", 0, imageA),
40 );
41 const retried = createDeterministicAttachmentId(
42 "thread-1",
43 chatAttachmentStableKey("message-1", 0, imageA),
44 );
45 // Editing the queued message keeps its id, and the new upload is first in its batch.
46 const edited = createDeterministicAttachmentId(
47 "thread-1",
48 chatAttachmentStableKey("message-1", 0, imageB),
49 );
50
51 expect(retried).toBe(sent);
52 expect(edited).not.toBe(sent);
53 });
54
55 it("keeps deterministic ids distinct when sanitized thread segments collide", () => {
56 const dotted = createDeterministicAttachmentId("thread.a", "message-1:0");
57 const dashed = createDeterministicAttachmentId("thread-a", "message-1:0");
58
59 expect(dotted).toBeTruthy();
60 expect(dashed).toBeTruthy();
61 expect(dotted).not.toBe(dashed);
62 expect(dotted && parseThreadSegmentFromAttachmentId(dotted)).toBe("thread-a");
63 expect(dashed && parseThreadSegmentFromAttachmentId(dashed)).toBe("thread-a");
64 });
65
66 it("sanitizes thread ids when creating attachment ids", () => {
67 const attachmentId = createAttachmentId("thread.folder/unsafe space");
68 expect(attachmentId).toBeTruthy();
69 if (!attachmentId) {
70 return;
71 }
72
73 const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
74 expect(threadSegment).toBeTruthy();
75 expect(threadSegment).toMatch(/^[a-z0-9_-]+$/i);
76 expect(threadSegment).not.toContain(".");
77 expect(threadSegment).not.toContain("%");
78 expect(threadSegment).not.toContain("/");
79 });
80
81 it("parses exact thread segments from attachment ids without prefix collisions", () => {
82 const fooId = "foo-00000000-0000-4000-8000-000000000001";
83 const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002";
84
85 expect(parseThreadSegmentFromAttachmentId(fooId)).toBe("foo");
86 expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar");
87 });
88
89 it("normalizes created thread segments to lowercase", () => {
90 const attachmentId = createAttachmentId("Thread.Foo");
91 expect(attachmentId).toBeTruthy();
92 if (!attachmentId) {
93 return;
94 }
95 expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
96 });
97
98 it("reserves the pending attachment segment", () => {
99 const pendingId = createPendingAttachmentId();
100 expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending");
101 expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/);
102 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending");
103 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe(
104 "pending_thread",
105 );
106 });
107
108 it("preserves safe file extensions in attachment ids and paths", () => {
109 const attachmentId = createPendingAttachmentId(".PDF");
110
111 expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending");
112 expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/);
113 expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf");
114 expect(attachmentFileExtension("report.PDF")).toBe(".pdf");
115 expect(attachmentFileExtension("report")).toBe(".bin");
116 expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin");
117 // ".part" is the in-flight upload suffix; storing it would make the file
118 // look like a stale partial to the sweep.
119 expect(attachmentFileExtension("archive.part")).toBe(".bin");
120 expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128);
121 });
122
123 it("resolves attachment path by id using the extension that exists on disk", () => {
124 const attachmentsDir = NodeFS.mkdtempSync(
125 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
126 );
127 try {
128 const attachmentId = "thread-1-attachment";
129 const pngPath = NodePath.join(attachmentsDir, `${attachmentId}.png`);
130 NodeFS.writeFileSync(pngPath, Buffer.from("hello"));
131
132 const resolved = resolveAttachmentPathById({
133 attachmentsDir,
134 attachmentId,
135 });
136 expect(resolved).toBe(pngPath);
137 } finally {
138 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
139 }
140 });
141
142 it("returns null when no attachment file exists for the id", () => {
143 const attachmentsDir = NodeFS.mkdtempSync(
144 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
145 );
146 try {
147 const resolved = resolveAttachmentPathById({
148 attachmentsDir,
149 attachmentId: "thread-1-missing",
150 });
151 expect(resolved).toBeNull();
152 } finally {
153 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
154 }
155 });
156
157 it("resolves generic attachments without scanning the attachment directory", () => {
158 const attachmentsDir = NodeFS.mkdtempSync(
159 NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"),
160 );
161 try {
162 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip";
163 const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`);
164 NodeFS.writeFileSync(archivePath, Buffer.from("archive"));
165
166 expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath);
167 } finally {
168 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
169 }
170 });
171
172 it("plans pending attachment claims with direct filename lookups", () => {
173 const attachmentsDir = NodeFS.mkdtempSync(
174 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
175 );
176 try {
177 const uuid = "00000000-0000-4000-8000-000000000001";
178 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
179 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
180
181 const claim = planAttachmentClaim({
182 attachmentsDir,
183 threadId: "thread-1",
184 attachmentId: `pending-${uuid}`,
185 });
186 expect(claim).toMatchObject({
187 ok: true,
188 currentPath: pendingPath,
189 });
190 if (!claim.ok) {
191 return;
192 }
193 expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1");
194 expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid);
195 expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`));
196 } finally {
197 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
198 }
199 });
200
201 it("rejects thread-owned attachments even when thread segments collide", () => {
202 const attachmentsDir = NodeFS.mkdtempSync(
203 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"),
204 );
205 try {
206 const attachmentId = "a-b-00000000-0000-4000-8000-000000000003";
207 NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels");
208
209 expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({
210 ok: false,
211 reason: "attachment must be a pending upload",
212 });
213 } finally {
214 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
215 }
216 });
217
218 it("removes expired pending and partial files without touching thread attachments", () => {
219 const attachmentsDir = NodeFS.mkdtempSync(
220 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"),
221 );
222 try {
223 const now = 1_800_000_000_000;
224 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
225 const uuid = "00000000-0000-4000-8000-000000000002";
226 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
227 const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`);
228 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
229 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
230 for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) {
231 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
232 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
233 }
234
235 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 });
236 expect(NodeFS.existsSync(pendingPath)).toBe(false);
237 expect(NodeFS.existsSync(pendingFilePath)).toBe(false);
238 expect(NodeFS.existsSync(partialPath)).toBe(false);
239 expect(NodeFS.existsSync(threadPath)).toBe(true);
240 } finally {
241 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
242 }
243 });
244});
245
246describe("threadHtmlRenderAttachmentIds", () => {
247 it("returns pages this thread published and skips other threads' and failed calls", () => {
248 const own = createAttachmentId("thread-a", "html")!;
249 const other = createAttachmentId("thread-b", "html")!;
250 const render = (attachmentId: string, extra = {}) => ({
251 toolName: "mcp__t3-code__html_render",
252 output: [
253 {
254 type: "text",
255 text: JSON.stringify({ htmlRender: { attachmentId, title: "x", height: 300 }, ...extra }),
256 },
257 ],
258 });
259 expect(
260 threadHtmlRenderAttachmentIds("thread-a", [
261 render(own),
262 render(other),
263 render(createAttachmentId("thread-a", "html")!, { isError: true }),
264 { toolName: "mcp__t3-code__html_preview", output: render(own).output },
265 ]),
266 ).toEqual([own]);
267 });
268
269 it("includes captured MCP App documents from any tool", () => {
270 const app = createAttachmentId("thread-a", "html")!;
271 expect(
272 threadHtmlRenderAttachmentIds("thread-a", [
273 {
274 toolName: "weather.get_weather",
275 output: {
276 t3McpApp: {
277 attachmentId: app,
278 server: "weather",
279 tool: "get_weather",
280 resourceUri: "ui://weather/dashboard",
281 },
282 result: { content: [] },
283 },
284 },
285 ]),
286 ).toEqual([app]);
287 });
288});