1import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts";
2import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 selectionTouchesMentionBoundary,
7 splitPromptIntoComposerSegments,
8} from "./composer-editor-mentions";
9import { formatTerminalContextReference } from "./lib/terminalContext";
10
11const terminalReference = formatTerminalContextReference({
12 id: "ctx-1",
13 terminalLabel: "Terminal 1",
14 lineStart: 3,
15 lineEnd: 4,
16});
17const terminalSegment = {
18 type: "context-reference" as const,
19 kind: "terminal",
20 contextId: "terminal_ctx-1",
21 label: "Terminal 1 lines 3-4",
22 source: terminalReference,
23};
24
25const citation: AssistantCitation = {
26 version: 1,
27 environmentId: EnvironmentId.make("remote/環境"),
28 threadId: ThreadId.make("thread-1"),
29 messageId: MessageId.make("message-1"),
30 text: 'Use @AGENTS.md, $review and "雪 ❄️" (carefully).',
31 start: 4,
32 end: 50,
33 prefix: "前: ",
34 suffix: " 後",
35};
36
37describe("splitPromptIntoComposerSegments", () => {
38 it("splits mention tokens followed by whitespace into mention segments", () => {
39 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([
40 { type: "text", text: "Inspect " },
41 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
42 { type: "text", text: " please" },
43 ]);
44 });
45
46 it("does not convert an incomplete trailing mention token", () => {
47 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md")).toEqual([
48 { type: "text", text: "Inspect @AGENTS.md" },
49 ]);
50 });
51
52 it("keeps newlines around mention tokens", () => {
53 expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([
54 { type: "text", text: "one\n" },
55 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
56 { type: "text", text: " \ntwo" },
57 ]);
58 });
59
60 it("splits quoted mention tokens containing whitespace", () => {
61 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
62 { type: "text", text: "Inspect " },
63 { type: "mention", path: "My File.md", source: '@"My File.md"' },
64 { type: "text", text: " please" },
65 ]);
66 });
67
68 it("unescapes quoted mention token content", () => {
69 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
70 { type: "text", text: "Inspect " },
71 {
72 type: "mention",
73 path: 'docs/My "File".md',
74 source: '@"docs/My \\"File\\".md"',
75 },
76 { type: "text", text: " please" },
77 ]);
78 });
79
80 it("splits generated markdown file links into mention segments", () => {
81 expect(
82 splitPromptIntoComposerSegments(
83 "Inspect [package.json](path/to/package.json) before continuing",
84 ),
85 ).toEqual([
86 { type: "text", text: "Inspect " },
87 {
88 type: "mention",
89 path: "path/to/package.json",
90 source: "[package.json](path/to/package.json)",
91 },
92 { type: "text", text: " before continuing" },
93 ]);
94 });
95
96 it("does not turn normal web links into file mention segments", () => {
97 expect(
98 splitPromptIntoComposerSegments("Read [the docs](https://example.com/docs) first"),
99 ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]);
100 });
101
102 it("keeps multiple assistant citations atomic next to punctuation and Unicode", () => {
103 const source = serializeAssistantCitation(citation);
104 const otherCitation = {
105 ...citation,
106 messageId: MessageId.make("message-2"),
107 text: "A second quote",
108 };
109 const otherSource = serializeAssistantCitation(otherCitation);
110
111 expect(splitPromptIntoComposerSegments(`前(${source}),${otherSource}後`)).toEqual([
112 { type: "text", text: "前(" },
113 { type: "citation", citation, source },
114 { type: "text", text: ")," },
115 { type: "citation", citation: otherCitation, source: otherSource },
116 { type: "text", text: "後" },
117 ]);
118 });
119
120 it("preserves exact citation source encoding for adjacent chips at the end of a prompt", () => {
121 const source = serializeAssistantCitation(citation).replaceAll("+", "%20");
122
123 expect(splitPromptIntoComposerSegments(`${source}${source}`)).toEqual([
124 { type: "citation", citation, source },
125 { type: "citation", citation, source },
126 ]);
127 });
128
129 it.each(["@", "@AGENTS.md"])(
130 "keeps a citation after the unfinished mention %s intact",
131 (prefix) => {
132 const source = serializeAssistantCitation(citation);
133
134 expect(splitPromptIntoComposerSegments(`${prefix}${source}`)).toEqual([
135 { type: "text", text: prefix },
136 { type: "citation", citation, source },
137 ]);
138 },
139 );
140
141 it("parses citations alongside file mentions, skills, and context references", () => {
142 const source = serializeAssistantCitation(citation);
143 const reference = formatTerminalContextReference({
144 id: "ctx-1",
145 terminalLabel: "Terminal 1",
146 lineStart: 3,
147 lineEnd: 4,
148 });
149
150 expect(
151 splitPromptIntoComposerSegments(`@AGENTS.md ${source}\n$review ${reference}${source}`),
152 ).toEqual([
153 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
154 { type: "text", text: " " },
155 { type: "citation", citation, source },
156 { type: "text", text: "\n" },
157 { type: "skill", name: "review", source: "$review" },
158 { type: "text", text: " " },
159 {
160 type: "context-reference",
161 kind: "terminal",
162 contextId: "terminal_ctx-1",
163 label: "Terminal 1 lines 3-4",
164 source: reference,
165 },
166 { type: "citation", citation, source },
167 ]);
168 });
169
170 it("keeps malformed citation links as editable text", () => {
171 const prompt = "[Assistant quote](t3-citation://v1/env/thread/message?text=missing+metadata)";
172
173 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
174 });
175
176 it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
177 "does not turn scoped package reference %s into file mention segments",
178 (reference) => {
179 const prompt = `Install ${reference} next`;
180 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
181 },
182 );
183
184 it("keeps IME-composed text containing a scoped package reference as text", () => {
185 const prompt = "入力 @expo/ui を追加";
186 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
187 });
188
189 it("turns canonical scoped folder links into file mention segments", () => {
190 expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([
191 { type: "text", text: "Inspect " },
192 {
193 type: "mention",
194 path: "@scope/pkg/sub",
195 source: "[sub](@scope/pkg/sub)",
196 },
197 { type: "text", text: " next" },
198 ]);
199 });
200
201 it("decodes reserved path characters from generated links", () => {
202 expect(
203 splitPromptIntoComposerSegments(
204 "Inspect [config#draft?.json](config%23draft%3F.json) before continuing",
205 ),
206 ).toEqual([
207 { type: "text", text: "Inspect " },
208 {
209 type: "mention",
210 path: "config#draft?.json",
211 source: "[config#draft?.json](config%23draft%3F.json)",
212 },
213 { type: "text", text: " before continuing" },
214 ]);
215 });
216
217 it("splits skill tokens followed by whitespace into skill segments", () => {
218 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
219 { type: "text", text: "Use " },
220 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
221 { type: "text", text: " please" },
222 ]);
223 });
224
225 it("splits digit-leading skill tokens into skill segments", () => {
226 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
227 { type: "text", text: "Use " },
228 { type: "skill", name: "2spec", source: "$2spec" },
229 { type: "text", text: " please" },
230 ]);
231 });
232
233 it("keeps digits-only dollar amounts and compact monetary expressions as text", () => {
234 expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([
235 { type: "text", text: "I'll pay $20 tomorrow" },
236 ]);
237 expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([
238 { type: "text", text: "Budget is $20k tomorrow" },
239 ]);
240 expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([
241 { type: "text", text: "Cost is $100M total" },
242 ]);
243 expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([
244 { type: "text", text: "Limit is $1e6 here" },
245 ]);
246 });
247
248 it("does not convert an incomplete trailing skill token", () => {
249 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([
250 { type: "text", text: "Use $review-follow-up" },
251 ]);
252 });
253
254 it("keeps context references at their prompt positions", () => {
255 expect(
256 splitPromptIntoComposerSegments(`Inspect ${terminalReference} @AGENTS.md please`),
257 ).toEqual([
258 { type: "text", text: "Inspect " },
259 terminalSegment,
260 { type: "text", text: " " },
261 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
262 { type: "text", text: " please" },
263 ]);
264 });
265
266 it("preserves consecutive context references without dropping positions", () => {
267 expect(splitPromptIntoComposerSegments(`${terminalReference}${terminalReference}tail`)).toEqual(
268 [terminalSegment, terminalSegment, { type: "text", text: "tail" }],
269 );
270 });
271
272 it("keeps skill parsing alongside mentions and context references", () => {
273 expect(
274 splitPromptIntoComposerSegments(
275 `Inspect ${terminalReference} $review-follow-up after @AGENTS.md `,
276 ),
277 ).toEqual([
278 { type: "text", text: "Inspect " },
279 terminalSegment,
280 { type: "text", text: " " },
281 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
282 { type: "text", text: " after " },
283 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
284 { type: "text", text: " " },
285 ]);
286 });
287
288 it("leaves a context link with an unparsable href as text", () => {
289 const prompt = "see [x](t3-context://v1/terminal/ctx 1) now";
290 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
291 });
292});
293
294describe("selectionTouchesMentionBoundary", () => {
295 it("does not treat text before a citation as an overlapping file mention", () => {
296 const prompt = `before @${serializeAssistantCitation(citation)}`;
297
298 expect(selectionTouchesMentionBoundary(prompt, "before".length, "before ".length)).toBe(false);
299 });
300
301 it("returns true when selection includes the whitespace after a mention", () => {
302 expect(
303 selectionTouchesMentionBoundary(
304 "hi @package.json there",
305 "hi @package.json".length,
306 "hi @package.json there".length,
307 ),
308 ).toBe(true);
309 });
310
311 it("returns true when selection includes the whitespace before a mention", () => {
312 expect(
313 selectionTouchesMentionBoundary(
314 "hi there @package.json later",
315 "hi there".length,
316 "hi there ".length,
317 ),
318 ).toBe(true);
319 });
320
321 it("returns false when selection starts after the mention boundary whitespace", () => {
322 expect(
323 selectionTouchesMentionBoundary(
324 "hi @package.json there",
325 "hi @package.json ".length,
326 "hi @package.json there".length,
327 ),
328 ).toBe(false);
329 });
330
331 it("returns true when selection includes whitespace after a mention following a context reference", () => {
332 const prompt = `${terminalReference} @AGENTS.md there`;
333 expect(
334 selectionTouchesMentionBoundary(
335 prompt,
336 `${terminalReference} @AGENTS.md`.length,
337 prompt.length,
338 ),
339 ).toBe(true);
340 });
341
342 it("returns true when selection includes whitespace after a quoted mention", () => {
343 expect(
344 selectionTouchesMentionBoundary(
345 'hi @"My File.md" there',
346 'hi @"My File.md"'.length,
347 'hi @"My File.md" there'.length,
348 ),
349 ).toBe(true);
350 });
351
352 it("returns true when selection includes whitespace after a markdown file link", () => {
353 const prompt = "hi [package.json](path/to/package.json) there";
354 expect(
355 selectionTouchesMentionBoundary(
356 prompt,
357 "hi [package.json](path/to/package.json)".length,
358 prompt.length,
359 ),
360 ).toBe(true);
361 });
362});