| 1 | import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; |
| 2 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | |
| 5 | import { |
| 6 | selectionTouchesMentionBoundary, |
| 7 | splitPromptIntoComposerSegments, |
| 8 | } from "./composer-editor-mentions"; |
| 9 | import { formatTerminalContextReference } from "./lib/terminalContext"; |
| 10 | |
| 11 | const terminalReference = formatTerminalContextReference({ |
| 12 | id: "ctx-1", |
| 13 | terminalLabel: "Terminal 1", |
| 14 | lineStart: 3, |
| 15 | lineEnd: 4, |
| 16 | }); |
| 17 | const 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 | |
| 25 | const 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 | |
| 37 | describe("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 | |
| 294 | describe("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 | }); |