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.
| ddb25a6 | 1 | /** |
| 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 | ||
| 14 | import { describe, it, expect, beforeEach, afterEach } from "bun:test"; | |
| 15 | import { | |
| 16 | runTriageAgent, | |
| 17 | normaliseTriagePayload, | |
| 18 | validateTriageArgs, | |
| 19 | estimateHaikuCents, | |
| 20 | renderTriageComment, | |
| 21 | buildRunSummary, | |
| 22 | type TriageClassification, | |
| 23 | } from "../../lib/agents/triage-agent"; | |
| 24 | ||
| 25 | const hadKey = !!process.env.ANTHROPIC_API_KEY; | |
| 26 | const originalKey = process.env.ANTHROPIC_API_KEY; | |
| 27 | ||
| 28 | beforeEach(() => { | |
| 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 | ||
| 34 | afterEach(() => { | |
| 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 | ||
| 46 | describe("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 | ||
| 109 | describe("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 | ||
| 193 | describe("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 | ||
| 225 | describe("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 | ||
| 268 | describe("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 | ||
| 315 | describe("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 | }); |