1import {
2 type ComposerContextId,
3 EnvironmentId,
4 MessageId,
5 OrchestrationMessageContext,
6 ThreadId,
7 type PreviewAnnotationPayload,
8} from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
11β
12import {
13 formatInlineContextReference,
14 removeInlineContextReference,
15} from "./composerContextReferences";
16import { describe, expect, it } from "vite-plus/test";
17β
18import {
19 asKnownContextRecord,
20 buildMessageContext,
21 composerContextImportLookupIds,
22 isSameComposerContextPayload,
23 previewAnnotationContextRecord,
24 previewAnnotationFromRecord,
25 pullRequestContextKindLabel,
26 resolveUserMessageContext,
27 reviewCommentContextLabel,
28 selectedMessageContextFragment,
29 reviewCommentContextRecord,
30 terminalContextRecord,
31 terminalContextReference,
32 terminalContextDraftFromRecord,
33 uploadedAttachmentContextRecord,
34} from "./composerContextRecords";
35β
36const decodeMessageContext = Schema.decodeUnknownSync(OrchestrationMessageContext);
37β
38const annotation: PreviewAnnotationPayload = {
39 id: "ann_1",
40 pageUrl: "http://localhost:3000/checkout",
41 pageTitle: "Checkout",
42 comment: " Make this bigger ",
43 elements: [
44 {
45 id: "el_1",
46 rect: { x: 0, y: 0, width: 10, height: 10 },
47 element: {
48 pageUrl: "http://localhost:3000/checkout",
49 pageTitle: "Checkout",
50 tagName: "BUTTON",
51 selector: "#pay",
52 htmlPreview: '<button id="pay">Pay</button>',
53 componentName: "Button",
54 source: null,
55 stack: [],
56 styles: "color: red;",
57 pickedAt: "2026-01-01T00:00:00.000Z",
58 },
59 },
60 ],
61 regions: [],
62 strokes: [],
63 styleChanges: [
64 { targetId: "el_1", selector: "#pay", property: "font-size", previousValue: "", value: "20px" },
65 ],
66 screenshot: null,
67 createdAt: "2026-01-01T00:00:00.000Z",
68};
69β
70describe("composerContextRecords", () => {
71 it("copies only ready or persisted server-side attachment IDs", () => {
72 const environmentId = EnvironmentId.make("env");
73 const image = {
74 type: "image" as const,
75 id: "local-image",
76 name: "shot.png",
77 mimeType: "image/png",
78 sizeBytes: 1,
79 file: new File(["x"], "shot.png"),
80 previewUrl: "blob:shot",
81 };
82 expect(uploadedAttachmentContextRecord(image, undefined)).toBeNull();
83 expect(
84 uploadedAttachmentContextRecord(image, { status: "uploading", environmentId, progress: 0.5 }),
85 ).toBeNull();
86 expect(
87 uploadedAttachmentContextRecord(image, {
88 status: "failed",
89 environmentId,
90 reason: "offline",
91 attachmentId: "unfinished",
92 }),
93 ).toBeNull();
94 expect(
95 uploadedAttachmentContextRecord(image, {
96 status: "ready",
97 environmentId,
98 attachmentId: "uploaded-image",
99 }),
100 ).toMatchObject({ attachmentId: "uploaded-image", contextId: "image_local-image" });
101 const file = {
102 type: "file" as const,
103 id: "local-file",
104 name: "notes.txt",
105 mimeType: "text/plain",
106 sizeBytes: 1,
107 file: null,
108 };
109 expect(uploadedAttachmentContextRecord(file, undefined)).toBeNull();
110 expect(
111 uploadedAttachmentContextRecord(
112 { ...file, uploadedAttachmentId: "persisted-file", uploadEnvironmentId: environmentId },
113 undefined,
114 ),
115 ).toMatchObject({ attachmentId: "persisted-file", contextId: "file_local-file" });
116 });
117 it("does not bind an annotation screenshot to a same-ID file", () => {
118 const context = buildMessageContext({
119 terminalContexts: [],
120 reviewComments: [],
121 previewAnnotations: [annotation],
122 attachments: [
123 {
124 attachment: {
125 type: "file",
126 id: annotation.id,
127 name: "notes.txt",
128 mimeType: "text/plain",
129 sizeBytes: 1,
130 file: null,
131 },
132 attachmentId: "uploaded-file",
133 },
134 ],
135 })!;
136 expect(context.records.map((record) => record.kind)).toEqual(["preview-annotation", "file"]);
137 expect(context.records[0]).not.toHaveProperty("screenshotContextId");
138 });
139 it.each(["x", "terminal_x"])(
140 "preserves canonical terminal IDs across repeated imports: %s",
141 (id) => {
142 const threadId = ThreadId.make("t1");
143 const record = terminalContextRecord({
144 id,
145 threadId,
146 terminalId: "default",
147 terminalLabel: "Terminal",
148 lineStart: 1,
149 lineEnd: 1,
150 text: "output",
151 createdAt: "2026-01-01T00:00:00.000Z",
152 });
153 const restored = terminalContextRecord(terminalContextDraftFromRecord(record, threadId));
154 expect(restored).toEqual(record);
155 expect(terminalContextRecord(terminalContextDraftFromRecord(restored, threadId))).toEqual(
156 record,
157 );
158 },
159 );
160 it("scopes colliding producer ids and links the annotation to its screenshot record", () => {
161 const id = "same.id:1";
162 const context = buildMessageContext({
163 terminalContexts: [
164 {
165 id,
166 threadId: ThreadId.make("t1"),
167 terminalId: "default",
168 terminalLabel: "Terminal",
169 lineStart: 1,
170 lineEnd: 1,
171 text: "output",
172 createdAt: "2026-01-01T00:00:00.000Z",
173 },
174 ],
175 reviewComments: [
176 {
177 id,
178 sectionId: "s",
179 sectionTitle: "Review",
180 filePath: "file.ts",
181 startIndex: 0,
182 endIndex: 0,
183 rangeLabel: "L1",
184 text: "Review",
185 diff: "",
186 },
187 ],
188 previewAnnotations: [{ ...annotation, id }],
189 attachments: [
190 {
191 attachment: {
192 type: "image",
193 id,
194 name: "shot.png",
195 mimeType: "image/png",
196 sizeBytes: 1,
197 file: new File(["x"], "shot.png"),
198 previewUrl: "blob:shot",
199 },
200 attachmentId: "uploaded-image",
201 },
202 {
203 attachment: {
204 type: "file",
205 id,
206 name: "file.txt",
207 mimeType: "text/plain",
208 sizeBytes: 1,
209 file: null,
210 },
211 attachmentId: "uploaded-file",
212 },
213 ],
214 })!;
215 expect(decodeMessageContext(context).records).toHaveLength(5);
216 const preview = context.records.find((record) => record.kind === "preview-annotation");
217 const image = context.records.find((record) => record.kind === "image");
218 expect(preview).toMatchObject({ screenshotContextId: image!.contextId });
219 expect(image).toMatchObject({ attachmentId: "uploaded-image" });
220 });
221β
222 it("shortens a long annotation comment label without splitting an emoji", () => {
223 const record = previewAnnotationContextRecord({
224 ...annotation,
225 comment: `${"a".repeat(46)}π and make the button bigger`,
226 });
227 expect(record.label).toBe(`${"a".repeat(46)}β¦`);
228 });
229β
230 it("restores multiple edits on one target without parsing display strings", () => {
231 const styleChanges = [
232 ...annotation.styleChanges,
233 {
234 targetId: "el_1",
235 selector: "#pay",
236 property: "content",
237 previousValue: "a β b",
238 value: "first\nsecond β third",
239 },
240 ];
241 const restored = previewAnnotationFromRecord(
242 previewAnnotationContextRecord({ ...annotation, styleChanges }),
243 );
244 expect(restored.styleChanges).toEqual(styleChanges);
245 expect(restored.elements[0]?.id).toBe("el_1");
246 });
247β
248 it("removes an expired terminal chip by its kind-scoped id, not the producer id", () => {
249 const context = {
250 id: "term-1",
251 threadId: ThreadId.make("t"),
252 createdAt: "2026-01-01T00:00:00.000Z",
253 terminalId: "default",
254 terminalLabel: "Terminal 1",
255 lineStart: 3,
256 lineEnd: 4,
257 text: "boom",
258 };
259 const reference = terminalContextReference(context);
260 expect(reference.contextId).toBe("terminal_term-1");
261β
262 const prompt = `prose ${formatInlineContextReference(reference)} tail`;
263 expect(removeInlineContextReference(prompt, context.id).prompt).toBe(prompt);
264 expect(removeInlineContextReference(prompt, reference.contextId).prompt).toBe("prose tail");
265 });
266β
267 it("clamps oversized review and terminal selections so the records still encode", () => {
268 const build = (diffLength: number) =>
269 reviewCommentContextRecord({
270 id: "rc-big",
271 sectionId: "file:a/b.ts",
272 sectionTitle: "File comment",
273 filePath: "a/b.ts",
274 startIndex: 0,
275 endIndex: 1,
276 rangeLabel: "L1",
277 text: "Why?",
278 diff: "d".repeat(diffLength),
279 });
280β
281 const atLimit = build(32_000);
282 expect(atLimit.diff).toHaveLength(32_000);
283 const overLimit = build(32_001);
284 expect(overLimit.diff.length).toBeLessThanOrEqual(32_000);
285 expect(overLimit.diff.endsWith("β¦ truncated β¦")).toBe(true);
286 expect(() => decodeMessageContext({ version: 1, records: [atLimit] })).not.toThrow();
287 expect(() => decodeMessageContext({ version: 1, records: [overLimit] })).not.toThrow();
288β
289 const terminal = terminalContextRecord({
290 id: "term-big",
291 threadId: ThreadId.make("t"),
292 createdAt: "2026-01-01T00:00:00.000Z",
293 terminalId: "default",
294 terminalLabel: "Terminal 1",
295 lineStart: 1,
296 lineEnd: 2_000,
297 text: `${"t".repeat(63_985)}${"π".repeat(8)}`,
298 });
299 expect(terminal.text).toBe(`${"t".repeat(63_985)}\nβ¦ truncated β¦`);
300 expect(decodeMessageContext({ version: 1, records: [terminal] }).records).toMatchObject([
301 { kind: "terminal", text: terminal.text },
302 ]);
303 });
304β
305 it("finds the destination collision for different legacy terminal messages", () => {
306 const legacy = (text: string) =>
307 asKnownContextRecord(
308 upgradeLegacyContextMessage(
309 `Inspect this\n\n<terminal_context>\n- Terminal 1 line 1:\n 1 | ${text}\n</terminal_context>`,
310 ).records[0],
311 )!;
312 const first = legacy("A");
313 const second = legacy("B");
314 if (first.kind !== "terminal" || second.kind !== "terminal") {
315 throw new Error("Expected legacy terminal records");
316 }
317 const destinationId = terminalContextReference(
318 terminalContextDraftFromRecord(first, ThreadId.make("t")),
319 ).contextId;
320β
321 expect(destinationId).toBe("terminal_legacy_terminal_1");
322 expect(composerContextImportLookupIds(second)[0]).toBe(destinationId);
323 expect(isSameComposerContextPayload(first, second)).toBe(false);
324 });
325β
326 it("treats an imported legacy id and its canonical reconstruction as the same excerpt", () => {
327 const draft = {
328 id: "term-1",
329 threadId: ThreadId.make("t"),
330 createdAt: "2026-01-01T00:00:00.000Z",
331 terminalId: "default",
332 terminalLabel: "Terminal 1",
333 lineStart: 1,
334 lineEnd: 2,
335 text: "A",
336 };
337 const canonical = terminalContextRecord(draft);
338 const imported = { ...canonical, contextId: "legacy_terminal_1" as ComposerContextId };
339β
340 expect(isSameComposerContextPayload(canonical, imported)).toBe(true);
341 expect(isSameComposerContextPayload(canonical, { ...canonical, text: "B" })).toBe(false);
342 });
343β
344 it("resolves structured context directly and upgrades legacy text otherwise", () => {
345 const structured = resolveUserMessageContext({
346 text: "hi [b.ts L4](t3-context://v1/review-comment/rc-1)",
347 context: {
348 version: 1,
349 records: [
350 reviewCommentContextRecord({
351 id: "rc-1",
352 sectionId: "s",
353 sectionTitle: "t",
354 filePath: "a/b.ts",
355 startIndex: 3,
356 endIndex: 3,
357 rangeLabel: "L4",
358 text: "",
359 diff: "",
360 }),
361 ],
362 },
363 });
364 expect(structured.recordsById.get("review-comment_rc-1")?.kind).toBe("review-comment");
365 const legacy = resolveUserMessageContext({
366 text: "hi\n\n<terminal_context>\n- T line 1:\n 1 | x\n</terminal_context>",
367 });
368 expect(legacy.text).toBe("hi\n\n[T line 1](t3-context://v1/terminal/legacy_terminal_1)");
369 expect(legacy.recordsById.get("legacy_terminal_1")?.kind).toBe("terminal");
370 });
371});
372β
373describe("pullRequestContextKindLabel", () => {
374 const comment = (url: string, isDraft: boolean) => ({
375 id: "pull-request-context:7",
376 sectionId: "pull-request:7",
377 sectionTitle: "PR #7",
378 filePath: "PR #7",
379 startIndex: 0,
380 endIndex: 0,
381 rangeLabel: "Title",
382 text: "",
383 diff: "",
384 pullRequest: {
385 number: 7,
386 title: "Title",
387 url,
388 headBranch: "feature",
389 baseBranch: "main",
390 state: "open" as const,
391 isDraft,
392 },
393 });
394β
395 it("names the change request the way its host does", () => {
396 expect(pullRequestContextKindLabel(comment("https://github.com/acme/app/pull/7", false))).toBe(
397 "Open pull request",
398 );
399 expect(
400 pullRequestContextKindLabel(comment("https://gitlab.com/acme/app/-/merge_requests/7", true)),
401 ).toBe("Draft merge request");
402 });
403});
404β
405describe("reviewCommentContextLabel", () => {
406 const selection = (sectionId: string) => ({
407 id: "selection",
408 sectionId,
409 sectionTitle: "Review",
410 filePath: "apps/web/src/page.tsx",
411 startIndex: 0,
412 endIndex: 0,
413 rangeLabel: "+12 to +18",
414 text: "",
415 diff: "",
416 });
417β
418 it("names the pull request a selection came from", () => {
419 expect(reviewCommentContextLabel(selection("pull-request:42"))).toBe("#42 page.tsx L12 to L18");
420 expect(reviewCommentContextLabel(selection("turn-diff"))).toBe("page.tsx L12 to L18");
421 });
422});
423β
424describe("producer ids that do not fit the grammar", () => {
425 it("folds review comment ids and keeps the raw id in the draft shape", () => {
426 const record = reviewCommentContextRecord({
427 id: "pull-request-finding:42",
428 sectionId: "s",
429 sectionTitle: "t",
430 filePath: "a/b.ts",
431 startIndex: 0,
432 endIndex: 0,
433 rangeLabel: "L1",
434 text: "",
435 diff: "",
436 });
437 expect(record.contextId).toMatch(/^review-comment_pull-request-finding-42-[0-9a-f]{16}$/);
438 expect(
439 resolveUserMessageContext({
440 text: `[b.ts L1](t3-context://v1/review-comment/${record.contextId})`,
441 context: { version: 1, records: [record] },
442 }).recordsById.has(record.contextId),
443 ).toBe(true);
444 });
445});
446β
447describe("selectedMessageContextFragment", () => {
448 const terminal = terminalContextRecord({
449 id: "term-1",
450 threadId: ThreadId.make("t"),
451 createdAt: "2026-01-01T00:00:00.000Z",
452 terminalId: "default",
453 terminalLabel: "Terminal 1",
454 lineStart: 1,
455 lineEnd: 1,
456 text: "A",
457 });
458 const review = reviewCommentContextRecord({
459 id: "rc-1",
460 sectionId: "s",
461 sectionTitle: "t",
462 filePath: "a/b.ts",
463 startIndex: 3,
464 endIndex: 3,
465 rangeLabel: "L4",
466 text: "",
467 diff: "",
468 });
469 const input = {
470 records: [terminal, review],
471 environmentId: EnvironmentId.make("env"),
472 threadId: ThreadId.make("t"),
473 messageId: MessageId.make("msg-1"),
474 };
475β
476 it("carries only records for chips inside the selection", () => {
477 const fragment = selectedMessageContextFragment({
478 ...input,
479 markdown: `see [b.ts L4](t3-context://v1/review-comment/${review.contextId})`,
480 });
481β
482 expect(fragment).toContain(review.contextId);
483 expect(fragment).not.toContain(terminal.contextId);
484 });
485});