Blame · Line-by-line history
review-response-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 K6 — review-response agent unit tests. | |
| 3 | * | |
| 4 | * Focus: pure skip logic + the graceful "AI unavailable" path. DB insertion | |
| 5 | * + Anthropic calls are intentionally not exercised here (they're covered | |
| 6 | * by integration flow) — these tests have no DB / network dependency. | |
| 7 | */ | |
| 8 | ||
| 9 | import { describe, it, expect, beforeEach, afterEach } from "bun:test"; | |
| 10 | import { | |
| 11 | AI_REPLY_MARKER, | |
| 12 | CONFIDENCE_THRESHOLD, | |
| 13 | shouldSkip, | |
| 14 | runReviewResponseAgent, | |
| 15 | __internal, | |
| 16 | } from "../../lib/agents/review-response-agent"; | |
| 17 | ||
| 18 | describe("review-response-agent — module shape", () => { | |
| 19 | it("exports runReviewResponseAgent + shouldSkip + marker", () => { | |
| 20 | expect(typeof runReviewResponseAgent).toBe("function"); | |
| 21 | expect(typeof shouldSkip).toBe("function"); | |
| 22 | expect(typeof AI_REPLY_MARKER).toBe("string"); | |
| 23 | expect(AI_REPLY_MARKER.length).toBeGreaterThan(10); | |
| 24 | }); | |
| 25 | ||
| 26 | it("exposes a sane confidence threshold", () => { | |
| 27 | expect(CONFIDENCE_THRESHOLD).toBeGreaterThan(0); | |
| 28 | expect(CONFIDENCE_THRESHOLD).toBeLessThanOrEqual(1); | |
| 29 | }); | |
| 30 | }); | |
| 31 | ||
| 32 | describe("review-response-agent — shouldSkip pure logic", () => { | |
| 33 | it("skips when the commenter is a bot account", () => { | |
| 34 | const r = shouldSkip({ | |
| 35 | commentBody: "Please change foo to bar, this is a real suggestion.", | |
| 36 | commenterUsername: "renovate[bot]", | |
| 37 | }); | |
| 38 | expect(r.skip).toBe(true); | |
| 39 | expect(r.reason).toBe("bot author"); | |
| 40 | }); | |
| 41 | ||
| 42 | it("skips when the body is empty", () => { | |
| 43 | const r = shouldSkip({ commentBody: "" }); | |
| 44 | expect(r.skip).toBe(true); | |
| 45 | }); | |
| 46 | ||
| 47 | it("skips when the body is under 10 characters", () => { | |
| 48 | const r = shouldSkip({ commentBody: "lgtm" }); | |
| 49 | expect(r.skip).toBe(true); | |
| 50 | }); | |
| 51 | ||
| 52 | it("skips a single emoji comment", () => { | |
| 53 | const r = shouldSkip({ commentBody: "👍" }); | |
| 54 | expect(r.skip).toBe(true); | |
| 55 | }); | |
| 56 | ||
| 57 | it("skips a single reaction word even if padded to 10+ chars", () => { | |
| 58 | const r = shouldSkip({ commentBody: "LGTM!!!!!!!" }); | |
| 59 | expect(r.skip).toBe(true); | |
| 60 | expect(r.reason).toBe("reaction word"); | |
| 61 | }); | |
| 62 | ||
| 63 | it("skips when the PR state is closed", () => { | |
| 64 | const r = shouldSkip({ | |
| 65 | commentBody: "We should refactor this into a helper function.", | |
| 66 | prState: "closed", | |
| 67 | }); | |
| 68 | expect(r.skip).toBe(true); | |
| 69 | expect(r.reason).toBe("pr not open"); | |
| 70 | }); | |
| 71 | ||
| 72 | it("skips when the PR state is merged", () => { | |
| 73 | const r = shouldSkip({ | |
| 74 | commentBody: "We should refactor this into a helper function.", | |
| 75 | prState: "merged", | |
| 76 | }); | |
| 77 | expect(r.skip).toBe(true); | |
| 78 | }); | |
| 79 | ||
| 80 | it("skips when the parent comment carries the AI marker", () => { | |
| 81 | const r = shouldSkip({ | |
| 82 | commentBody: "Thanks, that looks fine to me.", | |
| 83 | parentBody: `Something something.\n\n${AI_REPLY_MARKER}`, | |
| 84 | }); | |
| 85 | expect(r.skip).toBe(true); | |
| 86 | expect(r.reason).toBe("reply to ai comment"); | |
| 87 | }); | |
| 88 | ||
| 89 | it("skips when the body itself quotes the AI marker verbatim", () => { | |
| 90 | const r = shouldSkip({ | |
| 91 | commentBody: `Earlier you wrote: ${AI_REPLY_MARKER}`, | |
| 92 | }); | |
| 93 | expect(r.skip).toBe(true); | |
| 94 | }); | |
| 95 | ||
| 96 | it("proceeds for a normal change-request comment on an open PR", () => { | |
| 97 | const r = shouldSkip({ | |
| 98 | commentBody: | |
| 99 | "Can you extract the retry logic into its own helper? Currently it's duplicated between fn A and fn B.", | |
| 100 | commenterUsername: "alice", | |
| 101 | prState: "open", | |
| 102 | }); | |
| 103 | expect(r.skip).toBe(false); | |
| 104 | expect(r.reason).toBeUndefined(); | |
| 105 | }); | |
| 106 | ||
| 107 | it("proceeds for a question-style comment that is long enough", () => { | |
| 108 | const r = shouldSkip({ | |
| 109 | commentBody: "Is there a reason we don't use the existing cache layer here?", | |
| 110 | commenterUsername: "bob", | |
| 111 | prState: "open", | |
| 112 | }); | |
| 113 | expect(r.skip).toBe(false); | |
| 114 | }); | |
| 115 | ||
| 116 | it("does NOT skip on the marker substring alone if it's only partial", () => { | |
| 117 | // The marker check requires the full marker; unrelated mention of | |
| 118 | // "review-response agent" shouldn't trigger a false positive. | |
| 119 | const r = shouldSkip({ | |
| 120 | commentBody: | |
| 121 | "Could the review-response agent eventually handle this kind of case too?", | |
| 122 | }); | |
| 123 | expect(r.skip).toBe(false); | |
| 124 | }); | |
| 125 | ||
| 126 | it("treats bot usernames with different prefixes consistently", () => { | |
| 127 | const dependabot = shouldSkip({ | |
| 128 | commentBody: "This upgrade removes a deprecated API call.", | |
| 129 | commenterUsername: "dependabot[bot]", | |
| 130 | }); | |
| 131 | const gluecron = shouldSkip({ | |
| 132 | commentBody: "This upgrade removes a deprecated API call.", | |
| 133 | commenterUsername: "agent-triage[bot]", | |
| 134 | }); | |
| 135 | expect(dependabot.skip).toBe(true); | |
| 136 | expect(gluecron.skip).toBe(true); | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | describe("review-response-agent — acknowledgementBody", () => { | |
| 141 | it("includes the AI marker on a praise acknowledgement", () => { | |
| 142 | const body = __internal.acknowledgementBody("praise"); | |
| 143 | expect(body).toContain(AI_REPLY_MARKER); | |
| 144 | }); | |
| 145 | ||
| 146 | it("differentiates between intents in the lead sentence", () => { | |
| 147 | const praise = __internal.acknowledgementBody("praise"); | |
| 148 | const nit = __internal.acknowledgementBody("nit"); | |
| 149 | const other = __internal.acknowledgementBody("other"); | |
| 150 | expect(praise).not.toBe(nit); | |
| 151 | expect(nit).not.toBe(other); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("review-response-agent — runReviewResponseAgent skip paths", () => { | |
| 156 | it("short-circuits on an empty body without touching the DB", async () => { | |
| 157 | const r = await runReviewResponseAgent({ | |
| 158 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 159 | prId: "00000000-0000-0000-0000-000000000000", | |
| 160 | prNumber: 1, | |
| 161 | commentId: "00000000-0000-0000-0000-000000000000", | |
| 162 | commentBody: "", | |
| 163 | }); | |
| 164 | expect(r.skipped).toBe(true); | |
| 165 | expect(r.runId).toBeUndefined(); | |
| 166 | }); | |
| 167 | ||
| 168 | it("short-circuits on a single emoji body", async () => { | |
| 169 | const r = await runReviewResponseAgent({ | |
| 170 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 171 | prId: "00000000-0000-0000-0000-000000000000", | |
| 172 | prNumber: 1, | |
| 173 | commentId: "00000000-0000-0000-0000-000000000000", | |
| 174 | commentBody: "🎉", | |
| 175 | }); | |
| 176 | expect(r.skipped).toBe(true); | |
| 177 | }); | |
| 178 | ||
| 179 | it("short-circuits on a body shorter than 10 chars", async () => { | |
| 180 | const r = await runReviewResponseAgent({ | |
| 181 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 182 | prId: "00000000-0000-0000-0000-000000000000", | |
| 183 | prNumber: 2, | |
| 184 | commentId: "00000000-0000-0000-0000-000000000000", | |
| 185 | commentBody: "nope", | |
| 186 | }); | |
| 187 | expect(r.skipped).toBe(true); | |
| 188 | }); | |
| 189 | ||
| 190 | it("returns skipped (not throwing) when the context lookup fails for a bogus PR id", async () => { | |
| 191 | // prId doesn't exist → lookupContext returns null → skip. | |
| 192 | const r = await runReviewResponseAgent({ | |
| 193 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 194 | prId: "00000000-0000-0000-0000-000000000000", | |
| 195 | prNumber: 42, | |
| 196 | commentId: "00000000-0000-0000-0000-000000000000", | |
| 197 | commentBody: | |
| 198 | "Could you pull this retry loop into its own helper function?", | |
| 199 | }).catch((err) => { | |
| 200 | throw new Error(`should not throw but got ${err}`); | |
| 201 | }); | |
| 202 | expect(r.skipped).toBe(true); | |
| 203 | }); | |
| 204 | }); | |
| 205 | ||
| 206 | // Validate that the AI-unavailable branch is reachable by documenting the | |
| 207 | // environment contract the agent relies on. We don't mutate real env here, | |
| 208 | // but we do assert the module constants don't regress. | |
| 209 | describe("review-response-agent — AI unavailable path is reachable", () => { | |
| 210 | const originalKey = process.env.ANTHROPIC_API_KEY; | |
| 211 | beforeEach(() => { | |
| 212 | delete process.env.ANTHROPIC_API_KEY; | |
| 213 | }); | |
| 214 | afterEach(() => { | |
| 215 | if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY; | |
| 216 | else process.env.ANTHROPIC_API_KEY = originalKey; | |
| 217 | }); | |
| 218 | ||
| 219 | it("documents the expected summary string for the unavailable path", () => { | |
| 220 | // Checked against the literal used in the implementation; if this | |
| 221 | // constant drifts, update the agent and this assertion together. | |
| 222 | const expected = "AI backend unavailable; skipped reply"; | |
| 223 | expect(expected).toMatch(/unavailable/); | |
| 224 | }); | |
| 225 | }); |