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 chatAttachmentStableKey,
10 createAttachmentId,
11 createDeterministicAttachmentId,
12 createPendingAttachmentId,
13 parseAttachmentUuid,
14 planAttachmentClaim,
15 parseThreadSegmentFromAttachmentId,
16 sweepStalePendingAttachments,
17 threadHtmlRenderAttachmentIds,
18} from "./attachmentStore.ts";
19
20describe("attachmentStore", () => {
21 it("derives stable attachment ids for idempotent message retries", () => {
22 const first = createDeterministicAttachmentId("thread-1", "message-1:0");
23 const retry = createDeterministicAttachmentId("thread-1", "message-1:0");
24 const next = createDeterministicAttachmentId("thread-1", "message-1:1");
25
26 expect(first).toBe(retry);
27 expect(next).not.toBe(first);
28 expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1");
29 });
30
31 it("gives a queued edit's new image its own id instead of an earlier image's", () => {
32 const imageA = new Uint8Array([1, 2, 3]);
33 const imageB = new Uint8Array([4, 5, 6]);
34 const sent = createDeterministicAttachmentId(
35 "thread-1",
36 chatAttachmentStableKey("message-1", 0, imageA),
37 );
38 const retried = createDeterministicAttachmentId(
39 "thread-1",
40 chatAttachmentStableKey("message-1", 0, imageA),
41 );
42 const edited = createDeterministicAttachmentId(
43 "thread-1",
44 chatAttachmentStableKey("message-1", 0, imageB),
45 );
46
47 expect(retried).toBe(sent);
48 expect(edited).not.toBe(sent);
49 });
50
51 it("keeps deterministic ids distinct when sanitized thread segments collide", () => {
52 const dotted = createDeterministicAttachmentId("thread.a", "message-1:0");
53 const dashed = createDeterministicAttachmentId("thread-a", "message-1:0");
54
55 expect(dotted).toBeTruthy();
56 expect(dashed).toBeTruthy();
57 expect(dotted).not.toBe(dashed);
58 expect(dotted && parseThreadSegmentFromAttachmentId(dotted)).toBe("thread-a");
59 expect(dashed && parseThreadSegmentFromAttachmentId(dashed)).toBe("thread-a");
60 });
61
62 it("parses exact thread segments from attachment ids without prefix collisions", () => {
63 const fooId = "foo-00000000-0000-4000-8000-000000000001";
64 const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002";
65
66 expect(parseThreadSegmentFromAttachmentId(fooId)).toBe("foo");
67 expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar");
68 });
69
70 it("reserves the pending attachment segment", () => {
71 const pendingId = createPendingAttachmentId();
72 expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending");
73 expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/);
74 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending");
75 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe(
76 "pending_thread",
77 );
78 });
79
80 it("plans pending attachment claims with direct filename lookups", () => {
81 const attachmentsDir = NodeFS.mkdtempSync(
82 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
83 );
84 try {
85 const uuid = "00000000-0000-4000-8000-000000000001";
86 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
87 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
88
89 const claim = planAttachmentClaim({
90 attachmentsDir,
91 threadId: "thread-1",
92 attachmentId: `pending-${uuid}`,
93 });
94 expect(claim).toMatchObject({
95 ok: true,
96 currentPath: pendingPath,
97 });
98 if (!claim.ok) {
99 return;
100 }
101 expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1");
102 expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid);
103 expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`));
104 } finally {
105 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
106 }
107 });
108
109 it("rejects thread-owned attachments even when thread segments collide", () => {
110 const attachmentsDir = NodeFS.mkdtempSync(
111 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"),
112 );
113 try {
114 const attachmentId = "a-b-00000000-0000-4000-8000-000000000003";
115 NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels");
116
117 expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({
118 ok: false,
119 reason: "attachment must be a pending upload",
120 });
121 } finally {
122 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
123 }
124 });
125
126 it("removes expired pending and partial files without touching thread attachments", () => {
127 const attachmentsDir = NodeFS.mkdtempSync(
128 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"),
129 );
130 try {
131 const now = 1_800_000_000_000;
132 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
133 const uuid = "00000000-0000-4000-8000-000000000002";
134 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
135 const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`);
136 const sidecarPath = `${pendingFilePath}.txt`;
137 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
138 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
139 for (const filePath of [pendingPath, pendingFilePath, sidecarPath, threadPath, partialPath]) {
140 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
141 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
142 }
143
144 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 4 });
145 expect(NodeFS.existsSync(pendingPath)).toBe(false);
146 expect(NodeFS.existsSync(pendingFilePath)).toBe(false);
147 expect(NodeFS.existsSync(sidecarPath)).toBe(false);
148 expect(NodeFS.existsSync(partialPath)).toBe(false);
149 expect(NodeFS.existsSync(threadPath)).toBe(true);
150 } finally {
151 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
152 }
153 });
154});
155
156describe("threadHtmlRenderAttachmentIds", () => {
157 it("returns pages this thread published and skips other threads' and failed calls", () => {
158 const own = createAttachmentId("thread-a", "html")!;
159 const other = createAttachmentId("thread-b", "html")!;
160 const render = (attachmentId: string, extra = {}) => ({
161 toolName: "mcp__t3-code__html_render",
162 output: [
163 {
164 type: "text",
165 text: JSON.stringify({ htmlRender: { attachmentId, title: "x", height: 300 }, ...extra }),
166 },
167 ],
168 });
169 expect(
170 threadHtmlRenderAttachmentIds("thread-a", [
171 render(own),
172 render(other),
173 render(createAttachmentId("thread-a", "html")!, { isError: true }),
174 { toolName: "mcp__t3-code__html_preview", output: render(own).output },
175 ]),
176 ).toEqual([own]);
177 });
178
179 it("includes pages an OpenCode Code Mode execute script returned", () => {
180 const own = createAttachmentId("thread-a", "html")!;
181 expect(
182 threadHtmlRenderAttachmentIds("thread-a", [
183 {
184 toolName: "execute",
185 output: JSON.stringify({
186 htmlRender: { attachmentId: own, title: "x", height: 300 },
187 message: "Shown to the reader above your reply.",
188 }),
189 },
190 ]),
191 ).toEqual([own]);
192 });
193
194 it("includes captured MCP App documents from any tool", () => {
195 const app = createAttachmentId("thread-a", "html")!;
196 expect(
197 threadHtmlRenderAttachmentIds("thread-a", [
198 {
199 toolName: "weather.get_weather",
200 output: {
201 t3McpApp: {
202 attachmentId: app,
203 server: "weather",
204 tool: "get_weather",
205 resourceUri: "ui://weather/dashboard",
206 },
207 result: { content: [] },
208 },
209 },
210 ]),
211 ).toEqual([app]);
212 });
213});