| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | buildBranchNamePrompt, |
| 5 | buildCommitMessagePrompt, |
| 6 | buildPrContentPrompt, |
| 7 | buildReviewGuidePrompt, |
| 8 | buildThreadTitlePrompt, |
| 9 | } from "./textGenerationPrompts.ts"; |
| 10 | import { |
| 11 | normalizeCliError, |
| 12 | sanitizeThreadTitle, |
| 13 | toJsonSchemaObject, |
| 14 | } from "./textGenerationUtils.ts"; |
| 15 | import { TextGenerationError } from "@t3tools/contracts"; |
| 16 | |
| 17 | describe("buildCommitMessagePrompt", () => { |
| 18 | it("includes staged patch and summary in the prompt", () => { |
| 19 | const result = buildCommitMessagePrompt({ |
| 20 | branch: "main", |
| 21 | stagedSummary: "M README.md", |
| 22 | stagedPatch: "diff --git a/README.md b/README.md\n+hello", |
| 23 | includeBranch: false, |
| 24 | }); |
| 25 | |
| 26 | expect(result.prompt).toContain("Staged files:"); |
| 27 | expect(result.prompt).toContain("M README.md"); |
| 28 | expect(result.prompt).toContain("Staged patch:"); |
| 29 | expect(result.prompt).toContain("diff --git a/README.md b/README.md"); |
| 30 | expect(result.prompt).toContain("Branch: main"); |
| 31 | // Should NOT include the branch generation instruction |
| 32 | expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); |
| 33 | }); |
| 34 | |
| 35 | it("includes branch generation instruction when includeBranch is true", () => { |
| 36 | const result = buildCommitMessagePrompt({ |
| 37 | branch: "feature/foo", |
| 38 | stagedSummary: "M README.md", |
| 39 | stagedPatch: "diff", |
| 40 | includeBranch: true, |
| 41 | }); |
| 42 | |
| 43 | expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); |
| 44 | expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); |
| 45 | }); |
| 46 | |
| 47 | it("shows (detached) when branch is null", () => { |
| 48 | const result = buildCommitMessagePrompt({ |
| 49 | branch: null, |
| 50 | stagedSummary: "M a.ts", |
| 51 | stagedPatch: "diff", |
| 52 | includeBranch: false, |
| 53 | }); |
| 54 | |
| 55 | expect(result.prompt).toContain("Branch: (detached)"); |
| 56 | }); |
| 57 | |
| 58 | it("includes policy instructions", () => { |
| 59 | const result = buildCommitMessagePrompt({ |
| 60 | branch: "main", |
| 61 | stagedSummary: "M a.ts", |
| 62 | stagedPatch: "diff", |
| 63 | includeBranch: false, |
| 64 | policy: { |
| 65 | kind: "custom", |
| 66 | commitInstructions: "Use a terse repository-specific subject.", |
| 67 | inferRepositoryConventions: false, |
| 68 | }, |
| 69 | }); |
| 70 | |
| 71 | expect(result.prompt).toContain("Additional instructions:"); |
| 72 | expect(result.prompt).toContain("Use a terse repository-specific subject."); |
| 73 | }); |
| 74 | }); |
| 75 | |
| 76 | describe("buildPrContentPrompt", () => { |
| 77 | it("includes branch names, commits, and diff in the prompt", () => { |
| 78 | const result = buildPrContentPrompt({ |
| 79 | baseBranch: "main", |
| 80 | headBranch: "feature/auth", |
| 81 | commitSummary: "feat: add login page", |
| 82 | diffSummary: "3 files changed", |
| 83 | diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", |
| 84 | }); |
| 85 | |
| 86 | expect(result.prompt).toContain("Base branch: main"); |
| 87 | expect(result.prompt).toContain("Head branch: feature/auth"); |
| 88 | expect(result.prompt).toContain("Commits:"); |
| 89 | expect(result.prompt).toContain("feat: add login page"); |
| 90 | expect(result.prompt).toContain("Diff stat:"); |
| 91 | expect(result.prompt).toContain("3 files changed"); |
| 92 | expect(result.prompt).toContain("Diff patch:"); |
| 93 | expect(result.prompt).toContain("export function login()"); |
| 94 | expect(result.prompt).toContain("include headings '## Summary' and '## Testing'"); |
| 95 | }); |
| 96 | |
| 97 | it("follows a repository PR template instead of the default body headings", () => { |
| 98 | const result = buildPrContentPrompt({ |
| 99 | baseBranch: "main", |
| 100 | headBranch: "feature/auth", |
| 101 | commitSummary: "feat: add login page", |
| 102 | diffSummary: "3 files changed", |
| 103 | diffPatch: "diff", |
| 104 | changeRequestTemplate: "<!-- remove me -->\n## What changed\n\n## Verification", |
| 105 | policy: { |
| 106 | kind: "custom", |
| 107 | changeRequestInstructions: "Keep the title in sentence case.", |
| 108 | inferRepositoryConventions: false, |
| 109 | }, |
| 110 | }); |
| 111 | |
| 112 | expect(result.prompt).toContain("Keep the title in sentence case."); |
| 113 | expect(result.prompt).toContain("follow the repository change request template structure"); |
| 114 | expect(result.prompt).toContain("drop HTML comments from the template"); |
| 115 | expect(result.prompt).toContain("Repository change request template:"); |
| 116 | expect(result.prompt).toContain("<!-- remove me -->\n## What changed\n\n## Verification"); |
| 117 | expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'"); |
| 118 | }); |
| 119 | }); |
| 120 | |
| 121 | describe("buildBranchNamePrompt", () => { |
| 122 | it("requests a semantic prefix as part of the same branch response", () => { |
| 123 | const { prompt, outputSchema } = buildBranchNamePrompt({ |
| 124 | message: "Add search", |
| 125 | naming: { mode: "semantic", prefix: "ignored", instructions: "ignored instruction" }, |
| 126 | }); |
| 127 | expect(prompt).toContain("feat/add-search"); |
| 128 | expect(prompt).not.toContain("ignored instruction"); |
| 129 | expect(toJsonSchemaObject(outputSchema)).toMatchObject({ required: ["branch"] }); |
| 130 | }); |
| 131 | it("appends custom instructions without imposing a prefix, case or word limit", () => { |
| 132 | const { prompt } = buildBranchNamePrompt({ |
| 133 | message: "Add search", |
| 134 | naming: { |
| 135 | mode: "custom", |
| 136 | prefix: "ignored", |
| 137 | instructions: "Use Julius/ABC-123 and preserve capitalization.", |
| 138 | }, |
| 139 | }); |
| 140 | expect(prompt).toContain("Use Julius/ABC-123 and preserve capitalization."); |
| 141 | expect(prompt).toContain("complete branch name"); |
| 142 | expect(prompt).not.toContain("2-6 words"); |
| 143 | expect(prompt).not.toContain("lowercase"); |
| 144 | expect(prompt).not.toContain("no issue prefixes"); |
| 145 | }); |
| 146 | it("asks for just the fragment in static mode", () => { |
| 147 | const { prompt } = buildBranchNamePrompt({ |
| 148 | message: "Add search", |
| 149 | naming: { mode: "static", prefix: "team", instructions: "ignored instruction" }, |
| 150 | }); |
| 151 | expect(prompt).toContain("without a prefix or namespace"); |
| 152 | expect(prompt).not.toContain("ignored instruction"); |
| 153 | }); |
| 154 | |
| 155 | it("includes the user message in the prompt", () => { |
| 156 | const result = buildBranchNamePrompt({ |
| 157 | message: "Fix the login timeout bug", |
| 158 | }); |
| 159 | |
| 160 | expect(result.prompt).toContain("User message:"); |
| 161 | expect(result.prompt).toContain("Fix the login timeout bug"); |
| 162 | expect(result.prompt).not.toContain("Attachment metadata:"); |
| 163 | }); |
| 164 | |
| 165 | it("includes attachment metadata when attachments are provided", () => { |
| 166 | const result = buildBranchNamePrompt({ |
| 167 | message: "Fix the layout from screenshot", |
| 168 | attachments: [ |
| 169 | { |
| 170 | type: "image" as const, |
| 171 | id: "att-123", |
| 172 | name: "screenshot.png", |
| 173 | mimeType: "image/png", |
| 174 | sizeBytes: 12345, |
| 175 | }, |
| 176 | ], |
| 177 | }); |
| 178 | |
| 179 | expect(result.prompt).toContain("Attachment metadata:"); |
| 180 | expect(result.prompt).toContain("screenshot.png"); |
| 181 | expect(result.prompt).toContain("image/png"); |
| 182 | expect(result.prompt).toContain("12345 bytes"); |
| 183 | }); |
| 184 | }); |
| 185 | |
| 186 | describe("buildReviewGuidePrompt", () => { |
| 187 | const input = { |
| 188 | number: 42, |
| 189 | title: "Add search", |
| 190 | description: "", |
| 191 | baseBranch: "main", |
| 192 | baseCommit: "abc123", |
| 193 | settledReview: "", |
| 194 | }; |
| 195 | |
| 196 | it("adds the maintainer's preferences after the change context", () => { |
| 197 | const { prompt } = buildReviewGuidePrompt({ |
| 198 | ...input, |
| 199 | instructions: " Skip test files unless they change behavior. ", |
| 200 | }); |
| 201 | const preferences = prompt.indexOf("The maintainer's preferences for guides:"); |
| 202 | expect(preferences).toBeGreaterThan(prompt.indexOf("Already settled in review:")); |
| 203 | expect(prompt.slice(preferences)).toContain("Skip test files unless they change behavior."); |
| 204 | expect(prompt.slice(preferences)).toContain("do not change the JSON format"); |
| 205 | }); |
| 206 | |
| 207 | it("names the stack's other layers as context the guide may point at", () => { |
| 208 | const { prompt } = buildReviewGuidePrompt({ |
| 209 | ...input, |
| 210 | stack: [ |
| 211 | { number: 41, title: "Add the index", headSha: "aaa111", state: "merged" }, |
| 212 | { number: 42, title: "Add search", headSha: "bbb222", state: "open" }, |
| 213 | { number: 43, title: "Search UI", headSha: null, state: "open" }, |
| 214 | ], |
| 215 | }); |
| 216 | expect(prompt).toContain("1. #41: Add the index, merged, head aaa111"); |
| 217 | expect(prompt).toContain("2. #42 (this change): Add search\n"); |
| 218 | expect(prompt).toContain("3. #43: Search UI, head not available"); |
| 219 | expect(prompt).toContain("Never excerpt another layer's code"); |
| 220 | }); |
| 221 | |
| 222 | it("leaves the stack out for a change that is not in one", () => { |
| 223 | expect(buildReviewGuidePrompt(input).prompt).not.toContain("layer of a stack"); |
| 224 | expect( |
| 225 | buildReviewGuidePrompt({ |
| 226 | ...input, |
| 227 | stack: [{ number: 42, title: "Add search", headSha: "bbb222", state: "open" }], |
| 228 | }).prompt, |
| 229 | ).not.toContain("layer of a stack"); |
| 230 | }); |
| 231 | |
| 232 | it("leaves the section out when there are no preferences", () => { |
| 233 | expect(buildReviewGuidePrompt(input).prompt).not.toContain("preferences for guides"); |
| 234 | expect(buildReviewGuidePrompt({ ...input, instructions: " " }).prompt).not.toContain( |
| 235 | "preferences for guides", |
| 236 | ); |
| 237 | }); |
| 238 | }); |
| 239 | |
| 240 | describe("buildThreadTitlePrompt", () => { |
| 241 | it("requires each generated field in the strict response schema", () => { |
| 242 | const { outputSchema } = buildThreadTitlePrompt({ message: "Fix this" }); |
| 243 | expect(toJsonSchemaObject(outputSchema)).toMatchObject({ |
| 244 | required: ["title", "needsRefinement"], |
| 245 | properties: { title: { type: "string" }, needsRefinement: { type: "boolean" } }, |
| 246 | }); |
| 247 | }); |
| 248 | |
| 249 | it("includes the user message without absent attachment metadata", () => { |
| 250 | const result = buildThreadTitlePrompt({ |
| 251 | message: "Investigate reconnect regressions after session restore", |
| 252 | }); |
| 253 | |
| 254 | expect(result.prompt).toContain("User message:"); |
| 255 | expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); |
| 256 | expect(result.prompt).not.toContain("Attachment metadata:"); |
| 257 | }); |
| 258 | |
| 259 | it("includes attachment metadata when attachments are provided", () => { |
| 260 | const result = buildThreadTitlePrompt({ |
| 261 | message: "Name this thread from the screenshot", |
| 262 | attachments: [ |
| 263 | { |
| 264 | type: "image" as const, |
| 265 | id: "att-456", |
| 266 | name: "thread.png", |
| 267 | mimeType: "image/png", |
| 268 | sizeBytes: 67890, |
| 269 | }, |
| 270 | ], |
| 271 | }); |
| 272 | |
| 273 | expect(result.prompt).toContain("Attachment metadata:"); |
| 274 | expect(result.prompt).toContain("thread.png"); |
| 275 | expect(result.prompt).toContain("image/png"); |
| 276 | expect(result.prompt).toContain("67890 bytes"); |
| 277 | }); |
| 278 | |
| 279 | it("regenerates from recent thread contents and identifies the previous title", () => { |
| 280 | const result = buildThreadTitlePrompt({ |
| 281 | message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, |
| 282 | previousTitle: "Investigate reconnect regressions", |
| 283 | }); |
| 284 | |
| 285 | expect(result.prompt).toContain( |
| 286 | "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", |
| 287 | ); |
| 288 | expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); |
| 289 | expect(result.prompt).toContain("Thread contents:"); |
| 290 | expect(result.prompt).toContain("The remaining issue is stale session state"); |
| 291 | }); |
| 292 | |
| 293 | it("keeps the latest thread contents when regeneration context is truncated", () => { |
| 294 | const result = buildThreadTitlePrompt({ |
| 295 | message: `${"old context ".repeat(1_000)}\n\nASSISTANT:\nCurrent thread state`, |
| 296 | previousTitle: "Old title", |
| 297 | }); |
| 298 | |
| 299 | expect(result.prompt).toContain("[Earlier content truncated]"); |
| 300 | expect(result.prompt).toContain("Current thread state"); |
| 301 | expect(result.prompt).not.toContain("[truncated]"); |
| 302 | }); |
| 303 | |
| 304 | it("does not truncate an already-marked regeneration context twice", () => { |
| 305 | const retainedContext = "x".repeat(7_998); |
| 306 | const result = buildThreadTitlePrompt({ |
| 307 | message: `[Earlier content truncated]\n\n${retainedContext}`, |
| 308 | previousTitle: "Old title", |
| 309 | }); |
| 310 | |
| 311 | expect(result.prompt).toContain( |
| 312 | `Thread contents:\n[Earlier content truncated]\n\n${retainedContext}`, |
| 313 | ); |
| 314 | expect(result.prompt.match(/\[Earlier content truncated\]/g)).toHaveLength(1); |
| 315 | }); |
| 316 | }); |
| 317 | |
| 318 | describe("sanitizeThreadTitle", () => { |
| 319 | it.each([ |
| 320 | '{"title": "Refresh ev-stg APP ASG instances"}', |
| 321 | '{\n "title": "Refresh ev-stg APP ASG instances"\n}', |
| 322 | ])("unwraps a JSON title before normalizing: %s", (raw) => { |
| 323 | expect(sanitizeThreadTitle(raw)).toBe("Refresh ev-stg APP ASG instances"); |
| 324 | }); |
| 325 | |
| 326 | it.each([ |
| 327 | "Rolling ES Refresh ev-stg", |
| 328 | "Fix {title} interpolation", |
| 329 | '{"title": 42}', |
| 330 | '{"subject": "Fix parsing"}', |
| 331 | '{"title": "unfinished}', |
| 332 | ])("preserves text that is not a JSON title: %s", (raw) => { |
| 333 | expect(sanitizeThreadTitle(raw)).toBe(raw); |
| 334 | }); |
| 335 | |
| 336 | it("normalizes the extracted title", () => { |
| 337 | expect(sanitizeThreadTitle('{"title": " Fix reconnect failures "}')).toBe( |
| 338 | "Fix reconnect failures", |
| 339 | ); |
| 340 | expect(sanitizeThreadTitle('{"title": " "}')).toBe("New thread"); |
| 341 | expect( |
| 342 | sanitizeThreadTitle( |
| 343 | '{"title": "Reconnect failures after restart because the session state does not recover"}', |
| 344 | ), |
| 345 | ).toBe("Reconnect failures after restart because the session state does not recover"); |
| 346 | }); |
| 347 | |
| 348 | it("keeps complete titles for client display truncation", () => { |
| 349 | expect( |
| 350 | sanitizeThreadTitle( |
| 351 | ' "Reconnect failures after restart because the session state does not recover" ', |
| 352 | ), |
| 353 | ).toBe("Reconnect failures after restart because the session state does not recover"); |
| 354 | }); |
| 355 | |
| 356 | it("caps runaway titles so a paragraph cannot reach the sidebar", () => { |
| 357 | const words = Array.from({ length: 40 }, (_, index) => `word${index}`).join(" "); |
| 358 | const title = sanitizeThreadTitle(words); |
| 359 | expect(title.length).toBeLessThanOrEqual(120); |
| 360 | expect(title.endsWith("...")).toBe(true); |
| 361 | }); |
| 362 | }); |
| 363 | |
| 364 | describe("normalizeCliError", () => { |
| 365 | it("detects 'Command not found' and includes CLI name in the message", () => { |
| 366 | const error = normalizeCliError( |
| 367 | "claude", |
| 368 | "generateCommitMessage", |
| 369 | new Error("Command not found: claude"), |
| 370 | "Something went wrong", |
| 371 | ); |
| 372 | |
| 373 | expect(error).toBeInstanceOf(TextGenerationError); |
| 374 | expect(error.detail).toContain("Claude CLI"); |
| 375 | expect(error.detail).toContain("not available on PATH"); |
| 376 | }); |
| 377 | |
| 378 | it("uses the CLI name from the first argument for codex", () => { |
| 379 | const error = normalizeCliError( |
| 380 | "codex", |
| 381 | "generateBranchName", |
| 382 | new Error("Command not found: codex"), |
| 383 | "Something went wrong", |
| 384 | ); |
| 385 | |
| 386 | expect(error).toBeInstanceOf(TextGenerationError); |
| 387 | expect(error.detail).toContain("Codex CLI"); |
| 388 | expect(error.detail).toContain("not available on PATH"); |
| 389 | }); |
| 390 | |
| 391 | it("returns the error as-is if it is already a TextGenerationError", () => { |
| 392 | const existing = new TextGenerationError({ |
| 393 | operation: "generatePrContent", |
| 394 | detail: "Already wrapped", |
| 395 | }); |
| 396 | |
| 397 | const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); |
| 398 | |
| 399 | expect(result).toBe(existing); |
| 400 | }); |
| 401 | |
| 402 | it("wraps unknown non-Error values with the fallback message", () => { |
| 403 | const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); |
| 404 | |
| 405 | expect(result).toBeInstanceOf(TextGenerationError); |
| 406 | expect(result.detail).toBe("fallback"); |
| 407 | }); |
| 408 | |
| 409 | it("does not expose CLI failure details in the public error message", () => { |
| 410 | const result = normalizeCliError( |
| 411 | "codex", |
| 412 | "generateCommitMessage", |
| 413 | new Error("request failed with access_token=secret-token"), |
| 414 | "Failed to generate a commit message", |
| 415 | ); |
| 416 | |
| 417 | expect(result.detail).toBe("Failed to generate a commit message"); |
| 418 | expect(result.message).not.toContain("secret-token"); |
| 419 | }); |
| 420 | }); |