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("splits quoted mention tokens containing whitespace", () => {
47 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
48 { type: "text", text: "Inspect " },
49 { type: "mention", path: "My File.md", source: '@"My File.md"' },
50 { type: "text", text: " please" },
51 ]);
52 });
53
54 it("unescapes quoted mention token content", () => {
55 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
56 { type: "text", text: "Inspect " },
57 {
58 type: "mention",
59 path: 'docs/My "File".md',
60 source: '@"docs/My \\"File\\".md"',
61 },
62 { type: "text", text: " please" },
63 ]);
64 });
65
66 it("splits generated markdown file links into mention segments", () => {
67 expect(
68 splitPromptIntoComposerSegments(
69 "Inspect [package.json](path/to/package.json) before continuing",
70 ),
71 ).toEqual([
72 { type: "text", text: "Inspect " },
73 {
74 type: "mention",
75 path: "path/to/package.json",
76 source: "[package.json](path/to/package.json)",
77 },
78 { type: "text", text: " before continuing" },
79 ]);
80 });
81
82 it("does not turn normal web links into file mention segments", () => {
83 expect(
84 splitPromptIntoComposerSegments("Read [the docs](https://example.com/docs) first"),
85 ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]);
86 });
87
88 it("keeps multiple assistant citations atomic next to punctuation and Unicode", () => {
89 const source = serializeAssistantCitation(citation);
90 const otherCitation = {
91 ...citation,
92 messageId: MessageId.make("message-2"),
93 text: "A second quote",
94 };
95 const otherSource = serializeAssistantCitation(otherCitation);
96
97 expect(splitPromptIntoComposerSegments(`前(${source}),${otherSource}後`)).toEqual([
98 { type: "text", text: "前(" },
99 { type: "citation", citation, source },
100 { type: "text", text: ")," },
101 { type: "citation", citation: otherCitation, source: otherSource },
102 { type: "text", text: "後" },
103 ]);
104 });
105
106 it("preserves exact citation source encoding for adjacent chips at the end of a prompt", () => {
107 const source = serializeAssistantCitation(citation).replaceAll("+", "%20");
108
109 expect(splitPromptIntoComposerSegments(`${source}${source}`)).toEqual([
110 { type: "citation", citation, source },
111 { type: "citation", citation, source },
112 ]);
113 });
114
115 it.each(["@", "@AGENTS.md"])(
116 "keeps a citation after the unfinished mention %s intact",
117 (prefix) => {
118 const source = serializeAssistantCitation(citation);
119
120 expect(splitPromptIntoComposerSegments(`${prefix}${source}`)).toEqual([
121 { type: "text", text: prefix },
122 { type: "citation", citation, source },
123 ]);
124 },
125 );
126
127 it("parses citations alongside file mentions, skills, and context references", () => {
128 const source = serializeAssistantCitation(citation);
129 const reference = formatTerminalContextReference({
130 id: "ctx-1",
131 terminalLabel: "Terminal 1",
132 lineStart: 3,
133 lineEnd: 4,
134 });
135
136 expect(
137 splitPromptIntoComposerSegments(`@AGENTS.md ${source}\n$review ${reference}${source}`),
138 ).toEqual([
139 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
140 { type: "text", text: " " },
141 { type: "citation", citation, source },
142 { type: "text", text: "\n" },
143 { type: "skill", name: "review", source: "$review" },
144 { type: "text", text: " " },
145 {
146 type: "context-reference",
147 kind: "terminal",
148 contextId: "terminal_ctx-1",
149 label: "Terminal 1 lines 3-4",
150 source: reference,
151 },
152 { type: "citation", citation, source },
153 ]);
154 });
155
156 it("keeps malformed citation links as editable text", () => {
157 const prompt = "[Assistant quote](t3-citation://v1/env/thread/message?text=missing+metadata)";
158
159 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
160 });
161
162 it("decodes reserved path characters from generated links", () => {
163 expect(
164 splitPromptIntoComposerSegments(
165 "Inspect [config#draft?.json](config%23draft%3F.json) before continuing",
166 ),
167 ).toEqual([
168 { type: "text", text: "Inspect " },
169 {
170 type: "mention",
171 path: "config#draft?.json",
172 source: "[config#draft?.json](config%23draft%3F.json)",
173 },
174 { type: "text", text: " before continuing" },
175 ]);
176 });
177
178 it("splits skill tokens followed by whitespace into skill segments", () => {
179 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
180 { type: "text", text: "Use " },
181 { type: "skill", name: "review-follow-up", source: "$review-follow-up" },
182 { type: "text", text: " please" },
183 ]);
184 });
185
186 it("keeps context references at their prompt positions", () => {
187 expect(
188 splitPromptIntoComposerSegments(`Inspect ${terminalReference} @AGENTS.md please`),
189 ).toEqual([
190 { type: "text", text: "Inspect " },
191 terminalSegment,
192 { type: "text", text: " " },
193 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
194 { type: "text", text: " please" },
195 ]);
196 });
197});
198
199describe("selectionTouchesMentionBoundary", () => {
200 it("does not treat text before a citation as an overlapping file mention", () => {
201 const prompt = `before @${serializeAssistantCitation(citation)}`;
202
203 expect(selectionTouchesMentionBoundary(prompt, "before".length, "before ".length)).toBe(false);
204 });
205
206 it("returns true when selection includes the whitespace after a mention", () => {
207 expect(
208 selectionTouchesMentionBoundary(
209 "hi @package.json there",
210 "hi @package.json".length,
211 "hi @package.json there".length,
212 ),
213 ).toBe(true);
214 });
215
216 it("returns false when selection starts after the mention boundary whitespace", () => {
217 expect(
218 selectionTouchesMentionBoundary(
219 "hi @package.json there",
220 "hi @package.json ".length,
221 "hi @package.json there".length,
222 ),
223 ).toBe(false);
224 });
225
226 // regression: #2625 mentions with spaces are quoted; selection checks must span the quotes
227 it("returns true when selection includes whitespace after a quoted mention", () => {
228 expect(
229 selectionTouchesMentionBoundary(
230 'hi @"My File.md" there',
231 'hi @"My File.md"'.length,
232 'hi @"My File.md" there'.length,
233 ),
234 ).toBe(true);
235 });
236});