Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

triage-agent.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

triage-agent.test.tsBlame369 lines · 1 contributor
ddb25a6Claude1/**
2 * Block K4 — Triage agent tests.
3 *
4 * Pure-side tests only. We never touch Claude here — the happy path exercises
5 * the deterministic "no AI backend" branch by clearing ANTHROPIC_API_KEY in
6 * beforeEach, mirroring the style of src/__tests__/prod-signals.test.ts and
7 * src/__tests__/copilot.test.ts.
8 *
9 * The triage agent's DB writes degrade gracefully (it calls `startAgentRun`
10 * which catches exceptions and returns null), so running these tests without
11 * a live DB still yields a well-shaped result object — just with runId null.
12 */
13
14import { describe, it, expect, beforeEach, afterEach } from "bun:test";
15import {
16 runTriageAgent,
17 normaliseTriagePayload,
18 validateTriageArgs,
19 estimateHaikuCents,
20 renderTriageComment,
21 buildRunSummary,
22 type TriageClassification,
23} from "../../lib/agents/triage-agent";
24
25const hadKey = !!process.env.ANTHROPIC_API_KEY;
26const originalKey = process.env.ANTHROPIC_API_KEY;
27
28beforeEach(() => {
29 // Force the deterministic (no-AI) path. Individual tests that want to
30 // simulate a key can set it locally and restore before returning.
31 delete process.env.ANTHROPIC_API_KEY;
32});
33
34afterEach(() => {
35 if (hadKey) {
36 process.env.ANTHROPIC_API_KEY = originalKey;
37 } else {
38 delete process.env.ANTHROPIC_API_KEY;
39 }
40});
41
42// ---------------------------------------------------------------------------
43// validateTriageArgs — pure
44// ---------------------------------------------------------------------------
45
46describe("triage-agent — validateTriageArgs", () => {
47 const base = {
48 kind: "issue" as const,
49 repositoryId: "00000000-0000-0000-0000-000000000000",
50 itemId: "00000000-0000-0000-0000-000000000001",
51 itemNumber: 1,
52 title: "Something is broken",
53 body: "Details here.",
54 };
55
56 it("accepts a well-formed issue", () => {
57 expect(validateTriageArgs(base)).toEqual({ ok: true });
58 });
59
60 it("accepts a well-formed PR", () => {
61 expect(validateTriageArgs({ ...base, kind: "pr" })).toEqual({ ok: true });
62 });
63
64 it("rejects an empty title", () => {
65 const r = validateTriageArgs({ ...base, title: " " });
66 expect(r.ok).toBe(false);
67 if (!r.ok) expect(r.reason).toMatch(/empty title/i);
68 });
69
70 it("rejects a missing title", () => {
71 const r = validateTriageArgs({ ...base, title: undefined as unknown as string });
72 expect(r.ok).toBe(false);
73 });
74
75 it("rejects an invalid kind", () => {
76 const r = validateTriageArgs({
77 ...base,
78 kind: "wat" as unknown as "issue",
79 });
80 expect(r.ok).toBe(false);
81 if (!r.ok) expect(r.reason).toMatch(/kind/);
82 });
83
84 it("rejects a missing repositoryId", () => {
85 const r = validateTriageArgs({ ...base, repositoryId: "" });
86 expect(r.ok).toBe(false);
87 });
88
89 it("rejects a non-finite itemNumber", () => {
90 const r = validateTriageArgs({ ...base, itemNumber: NaN });
91 expect(r.ok).toBe(false);
92 });
93
94 it("rejects a zero / negative itemNumber", () => {
95 expect(validateTriageArgs({ ...base, itemNumber: 0 }).ok).toBe(false);
96 expect(validateTriageArgs({ ...base, itemNumber: -7 }).ok).toBe(false);
97 });
98
99 it("accepts an empty body (body is not required content-wise)", () => {
100 // A bare title with no body should still be triage-able.
101 expect(validateTriageArgs({ ...base, body: "" })).toEqual({ ok: true });
102 });
103});
104
105// ---------------------------------------------------------------------------
106// normaliseTriagePayload — pure JSON coercion
107// ---------------------------------------------------------------------------
108
109describe("triage-agent — normaliseTriagePayload", () => {
110 it("returns default classification for non-object input", () => {
111 const out = normaliseTriagePayload(null);
112 expect(out.category).toBe("chore");
113 expect(out.complexity).toBe("unknown");
114 expect(out.priority).toBe("unknown");
115 expect(out.labels).toEqual([]);
116 expect(out.suggestedReviewers).toEqual([]);
117 });
118
119 it("accepts a fully-valid blob untouched (modulo trimming)", () => {
120 const out = normaliseTriagePayload({
121 category: "bug",
122 labels: ["auth", "regression"],
123 complexity: "medium",
124 priority: "high",
125 riskArea: "session handling",
126 reasoning: "Race between session revoke and cookie set.",
127 suggestedReviewers: ["alice"],
128 });
129 expect(out.category).toBe("bug");
130 expect(out.complexity).toBe("medium");
131 expect(out.priority).toBe("high");
132 expect(out.labels).toEqual(["auth", "regression"]);
133 expect(out.suggestedReviewers).toEqual(["alice"]);
134 expect(out.riskArea).toBe("session handling");
135 });
136
137 it("coerces out-of-vocab category/complexity/priority to defaults", () => {
138 const out = normaliseTriagePayload({
139 category: "rocketship",
140 complexity: "XXL",
141 priority: "blocker",
142 reasoning: "nope",
143 });
144 expect(out.category).toBe("chore");
145 expect(out.complexity).toBe("unknown");
146 expect(out.priority).toBe("unknown");
147 });
148
149 it("drops unknown keys and non-string labels, dedupes + caps labels", () => {
150 const many = Array.from({ length: 30 }, (_, i) => `label-${i}`);
151 const out = normaliseTriagePayload({
152 category: "feature",
153 labels: [...many, "label-0", 42, null, "VALID-Label"],
154 bogus: "ignored",
155 complexity: "small",
156 priority: "low",
157 reasoning: "ok",
158 suggestedReviewers: ["bob", "bob", 9, "carol", "dave", "eve"],
159 });
160 // Caps at 6, lower-cased, deduped.
161 expect(out.labels.length).toBeLessThanOrEqual(6);
162 expect(new Set(out.labels).size).toBe(out.labels.length);
163 for (const l of out.labels) {
164 expect(l).toBe(l.toLowerCase());
165 }
166 // Reviewers: deduped, cap 3.
167 expect(out.suggestedReviewers.length).toBeLessThanOrEqual(3);
168 expect(new Set(out.suggestedReviewers).size).toBe(
169 out.suggestedReviewers.length
170 );
171 });
172
173 it("caps reasoning and riskArea length", () => {
174 const out = normaliseTriagePayload({
175 category: "docs",
176 reasoning: "x".repeat(5000),
177 riskArea: "y".repeat(500),
178 });
179 expect(out.reasoning.length).toBeLessThanOrEqual(1200);
180 expect(out.riskArea.length).toBeLessThanOrEqual(80);
181 });
182
183 it("falls back to a sentinel reasoning when the model omits one", () => {
184 const out = normaliseTriagePayload({ category: "bug" });
185 expect(out.reasoning).toMatch(/no reasoning/i);
186 });
187});
188
189// ---------------------------------------------------------------------------
190// Pricing
191// ---------------------------------------------------------------------------
192
193describe("triage-agent — estimateHaikuCents", () => {
194 it("returns 0 for zero tokens", () => {
195 expect(estimateHaikuCents(0, 0)).toBe(0);
196 });
197
198 it("is monotone in both inputs", () => {
199 const a = estimateHaikuCents(1000, 200);
200 const b = estimateHaikuCents(2000, 200);
201 const c = estimateHaikuCents(1000, 400);
202 expect(b).toBeGreaterThanOrEqual(a);
203 expect(c).toBeGreaterThanOrEqual(a);
204 });
205
206 it("rounds up so any positive usage is at least 1 cent", () => {
207 expect(estimateHaikuCents(1, 1)).toBe(1);
208 });
209
210 it("computes a reasonable value for a typical triage call (~2k in, ~200 out)", () => {
211 // (2000 * 0.25 + 200 * 1.25) / 1e6 * 100 = 0.075 cents → ceil to 1.
212 expect(estimateHaikuCents(2000, 200)).toBe(1);
213 });
214
215 it("ignores negative / non-finite inputs", () => {
216 expect(estimateHaikuCents(-100, -50)).toBe(0);
217 expect(estimateHaikuCents(Number.NaN, Number.NaN)).toBe(0);
218 });
219});
220
221// ---------------------------------------------------------------------------
222// Rendering
223// ---------------------------------------------------------------------------
224
225describe("triage-agent — renderTriageComment", () => {
226 const sample: TriageClassification = {
227 category: "bug",
228 labels: ["auth", "regression"],
229 complexity: "medium",
230 priority: "high",
231 riskArea: "session handling",
232 reasoning: "Race between revoke and set.",
233 suggestedReviewers: ["alice", "bob"],
234 };
235
236 it("begins with the ## Triage heading (required for the dedupe check)", () => {
237 const out = renderTriageComment("issue", sample, true);
238 expect(out.startsWith("## Triage")).toBe(true);
239 });
240
241 it("mentions that no AI backend is configured when aiAvailable=false", () => {
242 const out = renderTriageComment("issue", sample, false);
243 expect(out.toLowerCase()).toContain("no ai backend");
244 expect(out.toLowerCase()).toContain("manual triage");
245 });
246
247 it("omits suggested-reviewers section for issues even if classification has them", () => {
248 const out = renderTriageComment("issue", sample, true);
249 expect(out).not.toMatch(/Suggested reviewers/i);
250 });
251
252 it("includes suggested-reviewers section for PRs", () => {
253 const out = renderTriageComment("pr", sample, true);
254 expect(out).toMatch(/Suggested reviewers/i);
255 expect(out).toContain("@alice");
256 });
257
258 it("always includes the non-destructive disclaimer footer", () => {
259 const out = renderTriageComment("pr", sample, true);
260 expect(out).toMatch(/non-destructive suggestion/i);
261 });
262});
263
264// ---------------------------------------------------------------------------
265// buildRunSummary
266// ---------------------------------------------------------------------------
267
268describe("triage-agent — buildRunSummary", () => {
269 it("references 'no AI backend' when aiAvailable=false", () => {
270 const s = buildRunSummary(
271 {
272 category: "bug",
273 labels: [],
274 complexity: "small",
275 priority: "low",
276 riskArea: "x",
277 reasoning: "y",
278 suggestedReviewers: [],
279 },
280 false
281 );
282 expect(s.toLowerCase()).toContain("no ai backend");
283 });
284
285 it("mentions category + complexity when AI ran", () => {
286 const s = buildRunSummary(
287 {
288 category: "feature",
289 labels: [],
290 complexity: "large",
291 priority: "medium",
292 riskArea: "x",
293 reasoning: "y",
294 suggestedReviewers: [],
295 },
296 true
297 );
298 expect(s).toContain("feature");
299 expect(s).toContain("large");
300 });
301});
302
303// ---------------------------------------------------------------------------
304// runTriageAgent — integration-lite
305//
306// Without ANTHROPIC_API_KEY (forced by beforeEach) the agent hits the
307// deterministic path. We can't verify DB side-effects without a live Postgres,
308// but we CAN verify:
309// - it never throws
310// - it returns a well-shaped object
311// - the summary references the no-AI path when the DB is absent (in which
312// case startAgentRun returns null and runTriageAgent gives up cleanly).
313// ---------------------------------------------------------------------------
314
315describe("triage-agent — runTriageAgent graceful degradation", () => {
316 const base = {
317 kind: "issue" as const,
318 repositoryId: "00000000-0000-0000-0000-000000000000",
319 itemId: "00000000-0000-0000-0000-000000000001",
320 itemNumber: 1,
321 title: "Sample bug",
322 body: "Something went wrong.",
323 };
324
325 it("returns { ok: false, runId: null } for invalid args, no throw", async () => {
326 const r = await runTriageAgent({ ...base, title: "" });
327 expect(r.ok).toBe(false);
328 expect(r.runId).toBeNull();
329 expect(typeof r.summary).toBe("string");
330 expect(r.summary).toMatch(/invalid args/i);
331 });
332
333 it("returns a well-shaped result even when the DB is unavailable", async () => {
334 // startAgentRun catches DB failures and returns null; runTriageAgent
335 // propagates that as {ok:false, summary:'could not open agent_runs row'}.
336 // If a live DB IS connected, we instead get ok:true and a non-null runId.
337 // Either way the function resolves.
338 const r = await runTriageAgent(base);
339 expect(r).toBeDefined();
340 expect(typeof r.ok).toBe("boolean");
341 expect(typeof r.summary).toBe("string");
342 expect(r.summary.length).toBeGreaterThan(0);
343 });
344
345 it("never throws for a PR with an empty body", async () => {
346 await expect(
347 runTriageAgent({ ...base, kind: "pr", body: "" })
348 ).resolves.toBeDefined();
349 });
350
351 it("never throws for a PR whose body is missing entirely", async () => {
352 await expect(
353 runTriageAgent({
354 ...base,
355 kind: "pr",
356 body: undefined as unknown as string,
357 })
358 ).resolves.toBeDefined();
359 });
360
361 it("rejects kind='neither' without side effects", async () => {
362 const r = await runTriageAgent({
363 ...base,
364 kind: "neither" as unknown as "issue",
365 });
366 expect(r.ok).toBe(false);
367 expect(r.runId).toBeNull();
368 });
369});