diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 8fd0ba9a6..d6e4f64f0 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -26,6 +26,8 @@ import { computeLocalScorerTokens, } from "@loopover/engine"; import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop"; +// #6754: the same pure evaluator the remote MCP tool + /v1/loop/evaluate-escalation both call. +import { evaluateEscalation } from "@loopover/engine"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js"; import { formatTable } from "../lib/format-table.js"; @@ -532,6 +534,15 @@ const validateConfigShape = { source: z.enum(["repo_file", "api_record", "none"]).optional(), }; +// #6754: mirrors evaluateEscalationShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and +// the REST route all accept an identical payload. +const evaluateEscalationShape = { + runStatus: z.enum(["running", "converged", "abandoned", "error"]), + healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), + customerFlagged: z.boolean().optional(), + killRequested: z.boolean().optional(), +}; + const checkSlopRiskShape = { changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -841,6 +852,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.", }, + { + name: "loopover_evaluate_escalation", + category: "agent", + description: + "Decide whether a rented loop needs a human, and what action to take, from an already-computed run outcome, health tier, and operator/customer signals — the deterministic support/escalation-path logic. Source-free; returns shouldEscalate + action (none/notify/human_review/stop) + severity + reasons. It decides; the caller wires the action. Computed in-process; no API round-trip.", + }, { name: "loopover_check_issue_slop", category: "review", @@ -1401,6 +1418,18 @@ registerStdioTool( (input) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }), ); +registerStdioTool( + "loopover_evaluate_escalation", + { + description: stdioToolDescription("loopover_evaluate_escalation"), + inputSchema: evaluateEscalationShape, + }, + // Computed in-process from @loopover/engine (#6754) — the same pure evaluateEscalation the remote server + // (src/mcp/server.ts) and the /v1/loop/evaluate-escalation route both call, so all three surfaces return a + // byte-identical decision for identical input, and escalation checks work fully offline. + (input) => toolResult("LoopOver escalation decision.", evaluateEscalation(input)), +); + registerStdioTool( "loopover_check_issue_slop", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 9c6a175e3..935f762d4 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -197,6 +197,7 @@ import { } from "../services/control-panel-roles"; import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; +import { evaluateEscalation } from "../loop-escalation"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { buildMcpCompatibilityMetadata, @@ -478,6 +479,15 @@ const validateFocusManifestSchema = z.object({ // Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the loopover_check_slop_risk / // loopover_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check. +// #6754: mirrors the loopover_evaluate_escalation MCP tool's input shape exactly (src/mcp/server.ts) so the +// REST surface can never accept something the tool would reject, or vice versa. +const evaluateEscalationSchema = z.object({ + runStatus: z.enum(["running", "converged", "abandoned", "error"]), + healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(), + customerFlagged: z.boolean().optional(), + killRequested: z.boolean().optional(), +}); + const slopRiskSchema = z.object({ changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -3218,6 +3228,17 @@ export function createApp() { return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN }); }); + // #6754: REST mirror of the loopover_evaluate_escalation MCP tool, bringing it to the same REST/CLI parity + // its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk, directly above) already has. Both are + // pure, source-free evaluators over caller-supplied data, so this route delegates to the same + // `evaluateEscalation` the tool calls and adds no logic of its own -- it decides; the caller wires the action. + app.post("/v1/loop/evaluate-escalation", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = evaluateEscalationSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_evaluate_escalation_request", issues: parsed.error.issues }, 400); + return c.json(evaluateEscalation(parsed.data)); + }); + app.post("/v1/lint/issue-slop", async (c) => { const body = await c.req.json().catch(() => null); const parsed = issueSlopSchema.safeParse(body); diff --git a/test/unit/mcp-cli-evaluate-escalation-tool.test.ts b/test/unit/mcp-cli-evaluate-escalation-tool.test.ts new file mode 100644 index 000000000..6e308db9e --- /dev/null +++ b/test/unit/mcp-cli-evaluate-escalation-tool.test.ts @@ -0,0 +1,73 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { evaluateEscalation } from "../../src/loop-escalation"; + +// #6754: the local mirror of loopover_evaluate_escalation. Like its same-tier sibling loopover_check_slop_risk, +// it computes IN-PROCESS from @loopover/engine — no API round-trip — so escalation checks work fully offline. +// The point of these tests is cross-surface PARITY: the stdio tool must return exactly what the pure +// evaluateEscalation returns for identical input (the same function /v1/loop/evaluate-escalation delegates to). +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +beforeEach(async () => { + configDir = mkdtempSync(join(tmpdir(), "loopover-eval-escalation-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + // Pure + in-process: a black-holed API URL proves no round-trip happens. + env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_URL: "http://127.0.0.1:1", LOOPOVER_API_TIMEOUT_MS: "1000" }, + }); + client = new Client({ name: "eval-escalation-test", version: "0.0.1" }); + await client.connect(transport); +}); + +afterEach(async () => { + await client?.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +describe("loopover_evaluate_escalation stdio mirror (#6754)", () => { + it("registers the tool alongside its same-tier check_slop_risk sibling", async () => { + const names = new Set((await client.listTools()).tools.map((t) => t.name)); + expect(names).toContain("loopover_evaluate_escalation"); + expect(names).toContain("loopover_check_slop_risk"); + }); + + it("matches the pure evaluator for every precedence arm — offline, with no API reachable", async () => { + const cases = [ + { runStatus: "running", killRequested: true }, + { runStatus: "error" }, + { runStatus: "running", healthStatus: "critical" }, + { runStatus: "abandoned" }, + { runStatus: "running", customerFlagged: true }, + { runStatus: "running", healthStatus: "degraded" }, + { runStatus: "running", healthStatus: "healthy" }, + { runStatus: "converged" }, + ] as const; + for (const args of cases) { + const result = await client.callTool({ name: "loopover_evaluate_escalation", arguments: args }); + expect(result.isError, JSON.stringify(args)).toBeFalsy(); + // PARITY: identical to what the REST route returns, because both call this same function. + expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual( + JSON.parse(JSON.stringify(evaluateEscalation(args))), + ); + } + }); + + it("rejects invalid input (zod input-schema validation)", async () => { + for (const args of [{}, { runStatus: "bogus" }, { runStatus: "running", healthStatus: "on-fire" }, { runStatus: "running", killRequested: "yes" }]) { + const rejected = await client.callTool({ name: "loopover_evaluate_escalation", arguments: args }).then( + (r) => Boolean(r.isError), + () => true, + ); + expect(rejected, `${JSON.stringify(args)} should be rejected`).toBe(true); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 8e6af0efa..12e3cde60 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -3,7 +3,8 @@ // canonical loopover_-prefixed stdio tools are registered, none of their old gittensory_-prefixed // alias names resolve anymore, no description carries a stale deprecation notice, and the CLI's // `tools --json` listing stays in lockstep with what the live server actually registers. -// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.) +// (#6754 registered the evaluate-escalation mirror, taking the count from 64 to 65.) +// (#// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.) // (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.) // (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.) // (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.) @@ -52,14 +53,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 64 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 65 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(64); + expect(primary.length).toBe(65); expect(legacy.length).toBe(0); - expect(names.length).toBe(64); + expect(names.length).toBe(65); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -69,11 +70,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 64-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 65-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(64); + expect(payload.count).toBe(65); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-evaluate-escalation.test.ts b/test/unit/routes-evaluate-escalation.test.ts new file mode 100644 index 000000000..8c883ac54 --- /dev/null +++ b/test/unit/routes-evaluate-escalation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { evaluateEscalation } from "../../src/loop-escalation"; +import { createTestEnv } from "../helpers/d1"; + +// #6754: POST /v1/loop/evaluate-escalation — the REST mirror bringing loopover_evaluate_escalation to the same +// parity its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. The route delegates to +// the pure evaluateEscalation (covered by its own unit tests), so these pin the ROUTE contract: the decision is +// returned unmodified for every precedence arm, and a bad body is rejected. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); +const PATH = "/v1/loop/evaluate-escalation"; + +const post = (env: Env, body: unknown) => + createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +describe("POST /v1/loop/evaluate-escalation (#6754)", () => { + it("returns the escalation decision for a healthy running loop (no escalation)", async () => { + const env = createTestEnv(); + const response = await post(env, { runStatus: "running", healthStatus: "healthy" }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ shouldEscalate: false, action: "none", severity: "none" }); + }); + + it("escalates each precedence arm exactly as the pure evaluator does", async () => { + const env = createTestEnv(); + // One case per precedence branch: killRequested > error/critical > abandoned/customerFlagged > degraded. + const cases = [ + { runStatus: "running", killRequested: true }, + { runStatus: "error" }, + { runStatus: "running", healthStatus: "critical" }, + { runStatus: "abandoned" }, + { runStatus: "running", customerFlagged: true }, + { runStatus: "running", healthStatus: "degraded" }, + { runStatus: "converged" }, + ] as const; + for (const body of cases) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(200); + // PARITY: the route must return exactly what the pure evaluator the MCP tool calls returns. + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(evaluateEscalation(body)))); + } + }); + + it("rejects an invalid or unparseable body with 400", async () => { + const env = createTestEnv(); + for (const body of [{}, { runStatus: "bogus" }, { runStatus: "running", healthStatus: "on-fire" }, { runStatus: "running", killRequested: "yes" }]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_evaluate_escalation_request" }); + } + const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, createTestEnv()); + expect(malformed.status).toBe(400); + }); + + it("leaks no wallet/hotkey/trust-score terms", async () => { + const env = createTestEnv(); + const text = JSON.stringify(await (await post(env, { runStatus: "error" })).json()); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +});