import "../agent-run.test-env.js"; import assert from "node:assert/strict"; import { test } from "node:test"; import type Anthropic from "@anthropic-ai/sdk"; import { type GroupingLLMClient, runGroupingAgent, } from "./agent.js"; import type { GroupingCandidateIncident, GroupingNewIssue, } from "./domain.js"; function makeMessage( blocks: Anthropic.Messages.ContentBlock[], ): Anthropic.Messages.Message { return { id: "msg", type: "message", role: "assistant", model: "test", stop_reason: "tool_use", stop_sequence: null, usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: null, cache_read_input_tokens: null, server_tool_use: null, service_tier: null, }, content: blocks, container: null, } as unknown as Anthropic.Messages.Message; } function toolUse(name: string, input: unknown, id = "tu_1"): Anthropic.Messages.ToolUseBlock { return { type: "tool_use", name, input: input as object, id } as Anthropic.Messages.ToolUseBlock; } function textBlock(text: string): Anthropic.Messages.ContentBlock { return { type: "text", text, citations: null } as unknown as Anthropic.Messages.ContentBlock; } function makeCandidate(id: string, overrides: Partial = {}): GroupingCandidateIncident { return { id, title: "DB unreachable", service: "api", firstSeen: "2026-05-22T00:00:00Z", lastSeen: "2026-05-23T00:00:00Z", issueCount: 1, representative: { exceptionType: "ECONNREFUSED", message: "conn refused", topFrame: "db.query", normalizedFrames: ["db.query"], traceId: "t1", spanId: "s1", resourceAttrs: { "deployment.environment": "production" }, }, ...overrides, }; } const NEW_ISSUE: GroupingNewIssue = { id: "new-1", title: "ECONNREFUSED to db", service: "api", exceptionType: "ECONNREFUSED", message: "conn refused", topFrame: "db.query", normalizedFrames: ["db.query"], stacktrace: null, traceId: null, spanId: null, }; function makeDeps( responses: Array, overrides: Partial<{ maxIterations: number; accountantCalls: Array<{ input: number; output: number }>; accountantRejects: boolean; }> = {}, ) { let turn = 0; const client: GroupingLLMClient = { async send() { const blocks = responses[turn] ?? responses[responses.length - 1]; turn += 1; return makeMessage(blocks ?? []); }, }; const accountantCalls = overrides.accountantCalls ?? []; return { client, model: "test-model", maxIterations: overrides.maxIterations ?? 5, accountant: { async record(input: { usage: { inputTokens: number; outputTokens: number } }) { if (overrides.accountantRejects) throw new Error("metering unavailable"); accountantCalls.push({ input: input.usage.inputTokens, output: input.usage.outputTokens }); }, }, }; } test("runGroupingAgent: immediate decide_grouping standalone → standalone verdict", async () => { const deps = makeDeps([ [ toolUse( "decide_grouping", { decision: "standalone", evidence: "Nothing in common" }, "t1", ), ], ]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.deepEqual(verdict, { decision: "standalone", evidence: "Nothing in common" }); }); test("runGroupingAgent: join requires prior inspect_incident (else the tool result is is_error and loop continues)", async () => { // Turn 1: try to join "a" without inspecting → error. // Turn 2: inspect_incident "a". // Turn 3: decide_grouping join "a" with sufficient evidence. const deps = makeDeps([ [ toolUse( "decide_grouping", { decision: "join", incidentId: "a", evidence: "trace ids are identical between the two errors", }, "t_join_first", ), ], [toolUse("inspect_incident", { incident_id: "a" }, "t_inspect")], [ toolUse( "decide_grouping", { decision: "join", incidentId: "a", evidence: "trace ids are identical between the two errors", }, "t_join_again", ), ], ]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.equal(verdict.decision, "join"); if (verdict.decision === "join") { assert.equal(verdict.incidentId, "a"); assert.equal(verdict.evidence, "trace ids are identical between the two errors"); } }); test("runGroupingAgent: malformed decide_grouping input → is_error result, agent gets another shot", async () => { const deps = makeDeps([ [toolUse("decide_grouping", { decision: "yes-maybe" }, "t_bad")], [toolUse("decide_grouping", { decision: "standalone", evidence: "ok" }, "t_good")], ]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.deepEqual(verdict, { decision: "standalone", evidence: "ok" }); }); test("runGroupingAgent: search → inspect → decide chain", async () => { const deps = makeDeps([ [toolUse("search_incidents", { query: "db" }, "t_search")], [toolUse("inspect_incident", { incident_id: "a" }, "t_inspect")], [ toolUse( "decide_grouping", { decision: "join", incidentId: "a", evidence: "both fail on the same upstream postgres host and port", }, "t_decide", ), ], ]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.equal(verdict.decision, "join"); }); test("runGroupingAgent: text-only fallback parses JSON verdict from text", async () => { const deps = makeDeps([ [ textBlock( JSON.stringify({ decision: "join", incidentId: "a", evidence: "very strong evidence with at least 20 chars yes", }), ), ], ]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.equal(verdict.decision, "join"); }); test("runGroupingAgent: text-only with non-JSON returns standalone with explanatory evidence", async () => { const deps = makeDeps([[textBlock("I'd rather not say")]]); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.deepEqual(verdict, { decision: "standalone", evidence: "Model did not call a grouping tool.", }); }); test("runGroupingAgent: exhausts iteration budget → standalone with budget message", async () => { // The agent only ever searches. const deps = makeDeps( [[toolUse("search_incidents", { query: "x" }, "loop")]], { maxIterations: 2 }, ); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.deepEqual(verdict, { decision: "standalone", evidence: "Grouping agent exhausted its tool-use budget without a valid decision.", }); }); test("runGroupingAgent: records token usage every iteration", async () => { const calls: Array<{ input: number; output: number }> = []; const deps = makeDeps( [ [toolUse("search_incidents", { query: "x" }, "t1")], [toolUse("decide_grouping", { decision: "standalone" }, "t2")], ], { accountantCalls: calls }, ); await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.equal(calls.length, 2); }); test("runGroupingAgent: metering failures do not abort the grouping decision", async () => { const deps = makeDeps( [[toolUse("decide_grouping", { decision: "standalone", evidence: "Nothing in common" }, "t1")]], { accountantRejects: true }, ); const verdict = await runGroupingAgent( { projectName: "p", newIssue: NEW_ISSUE, candidates: [makeCandidate("a")] }, deps, ); assert.deepEqual(verdict, { decision: "standalone", evidence: "Nothing in common" }); });