1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 buildBranchNamePrompt,
5 buildCommitMessagePrompt,
6 buildPrContentPrompt,
7 buildReviewGuidePrompt,
8 buildThreadTitlePrompt,
9} from "./textGenerationPrompts.ts";
10import {
11 normalizeCliError,
12 sanitizeThreadTitle,
13 toJsonSchemaObject,
14} from "./textGenerationUtils.ts";
15import { TextGenerationError } from "@t3tools/contracts";
16
17describe("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
76describe("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
121describe("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
186describe("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
240describe("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
318describe("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
364describe("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});