diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index cf3bd67fac..bcaf13a814 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -20,6 +20,9 @@ import { // #6269: the same manifest-validation builder the remote server uses, so `loopover_validate_config` // can validate a `.loopover.yml` in-process instead of round-tripping to the API. buildFocusManifestValidation, + // #6150: the same deterministic token-score computation the remote server's loopover_run_local_scorer + // wraps, so it works fully offline here too. + computeLocalScorerTokens, } from "@loopover/engine"; import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop"; import { z } from "zod"; @@ -103,6 +106,109 @@ const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage // purpose. Do not "sync" it to the engine list. const MAINTAIN_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"]; const MAINTAIN_AUTONOMY_LEVELS = ["observe", "auto_with_approval", "auto"]; + +// #6150 — plan-DAG step tracking for loopover_build_plan/loopover_plan_status/loopover_record_step_result. +// Hand-duplicated from src/services/plan-dag.ts (packages/loopover-engine/src/services/plan-dag.ts is NOT +// where it lives -- this module was never extracted to @loopover/engine, so there is nothing to import from +// the published package's export map), same rationale as MAINTAIN_ACTION_CLASSES/AUTONOMY_LEVELS above: this +// file resolves @loopover/engine through the published package, whose export map does not surface it. +// PURE + stateless (no DB, no repo/network access) -- the harness performs each step's real work and calls +// loopover_record_step_result to report it back; this only advances the in-memory state machine the caller +// passes in and gets back on every call. +const DEFAULT_PLAN_MAX_ATTEMPTS = 1; + +function buildPlanDag(steps) { + return { + steps: steps.map((step) => ({ + id: step.id, + title: step.title, + ...(step.actionClass !== undefined ? { actionClass: step.actionClass } : {}), + dependsOn: [...new Set((step.dependsOn ?? []).filter((dep) => dep !== step.id))], + status: "pending", + attempts: 0, + maxAttempts: Math.min(10, Math.max(1, Math.trunc(step.maxAttempts ?? DEFAULT_PLAN_MAX_ATTEMPTS))), + })), + }; +} + +function validatePlanDag(plan) { + const errors = []; + const ids = plan.steps.map((step) => step.id); + const idSet = new Set(ids); + if (idSet.size !== ids.length) errors.push("duplicate step ids"); + for (const step of plan.steps) { + for (const dep of step.dependsOn) { + if (!idSet.has(dep)) errors.push(`step ${step.id} depends on unknown step ${dep}`); + } + } + const color = new Map(); + const byId = new Map(plan.steps.map((step) => [step.id, step])); + const hasCycle = (id) => { + color.set(id, 1); + for (const dep of byId.get(id)?.dependsOn ?? []) { + const depColor = color.get(dep) ?? 0; + if (depColor === 1) return true; + if (depColor === 0 && byId.has(dep) && hasCycle(dep)) return true; + } + color.set(id, 2); + return false; + }; + for (const step of plan.steps) { + if ((color.get(step.id) ?? 0) === 0 && hasCycle(step.id)) { + errors.push("plan has a dependency cycle"); + break; + } + } + return { valid: errors.length === 0, errors }; +} + +const isPlanStepDone = (status) => status === "completed" || status === "skipped"; + +function nextReadySteps(plan) { + const statusById = new Map(plan.steps.map((step) => [step.id, step.status])); + return plan.steps.filter((step) => step.status === "pending" && step.dependsOn.every((dep) => isPlanStepDone(statusById.get(dep) ?? "pending"))); +} + +function mapPlanStep(plan, stepId, update) { + return { steps: plan.steps.map((step) => (step.id === stepId ? update(step) : step)) }; +} + +function applyStepResult(plan, stepId, result) { + return mapPlanStep(plan, stepId, (step) => { + if (isPlanStepDone(step.status) || step.status === "failed") return step; + if (result.outcome === "completed") return { ...step, status: "completed", lastError: null }; + if (result.outcome === "skipped") return { ...step, status: "skipped", lastError: null }; + const attempts = step.attempts + 1; + const exhausted = attempts >= step.maxAttempts; + return { ...step, attempts, status: exhausted ? "failed" : "pending", lastError: result.error ?? "step failed" }; + }); +} + +function planProgress(plan) { + const count = (status) => plan.steps.filter((step) => step.status === status).length; + const completed = count("completed"); + const skipped = count("skipped"); + const failed = count("failed"); + const running = count("running"); + const pending = count("pending"); + const total = plan.steps.length; + let status; + if (total > 0 && completed + skipped === total) status = "completed"; + else if (failed > 0) status = "failed"; + else if (running > 0) status = "running"; + else if (pending > 0 && nextReadySteps(plan).length === 0) status = "blocked"; + else status = "pending"; + return { total, completed, failed, running, pending, skipped, status }; +} + +function planView(plan) { + return { + plan, + progress: planProgress(plan), + readySteps: nextReadySteps(plan).map((step) => ({ id: step.id, title: step.title })), + validation: validatePlanDag(plan), + }; +} const AGENT_PROFILES = { "miner-planner": { id: "miner-planner", @@ -425,6 +531,78 @@ const checkIssueSlopShape = { body: z.string().max(40000).optional(), }; +// #6150 — loopover_run_local_scorer's input, mirroring the remote server's changedFileSchema/validationEntrySchema. +const localScorerChangedFileShape = z + .object({ + path: z.string().min(1).max(400), + previousPath: z.string().min(1).max(400).optional(), + additions: z.number().int().min(0).optional(), + deletions: z.number().int().min(0).optional(), + status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), + binary: z.boolean().optional(), + }) + .strict(); +const localScorerValidationShape = z + .object({ + command: z.string().min(1).max(400), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), + summary: z.string().max(2000).optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), + }) + .strict(); +const runLocalScorerShape = { + changedFiles: z.array(localScorerChangedFileShape).min(1).max(500), + validation: z.array(localScorerValidationShape).max(50).optional(), +}; + +// #6150 — loopover_build_plan/loopover_plan_status/loopover_record_step_result's input, mirroring the remote +// server's rawPlanStepSchema/planStepSchema/planDagSchema (src/mcp/server.ts). +const rawPlanStepShape = z + .object({ + id: z.string().min(1).max(100), + title: z.string().min(1).max(300), + actionClass: z.string().min(1).max(60).optional(), + dependsOn: z.array(z.string().min(1).max(100)).max(50).optional(), + maxAttempts: z.number().int().min(1).max(10).optional(), + }) + .strict(); +const planStepShape = z + .object({ + id: z.string().min(1).max(100), + title: z.string().min(1).max(300), + actionClass: z.string().min(1).max(60).optional(), + dependsOn: z.array(z.string().min(1).max(100)).max(50), + status: z.enum(["pending", "running", "completed", "failed", "skipped"]), + attempts: z.number().int().min(0), + maxAttempts: z.number().int().min(1).max(10), + lastError: z.string().max(2000).nullable().optional(), + }) + .strict(); +const planDagShape = z.object({ steps: z.array(planStepShape).max(100) }).strict(); +const buildPlanShape = { steps: z.array(rawPlanStepShape).min(1).max(100) }; +const planStatusShape = { plan: planDagShape }; +const recordStepResultShape = { + plan: planDagShape, + stepId: z.string().min(1).max(100), + outcome: z.enum(["completed", "failed", "skipped"]), + error: z.string().max(2000).optional(), +}; + +// #6150 — loopover_predict_gate's input, mirroring the remote server's predictGateShape. Metadata-only (no +// git/workspace context needed): predicts the gate outcome for a PLANNED PR before any local code exists, the +// same use case loopover_preflight_pr already serves for lane/duplicate/linked-issue checks. +const predictGateShape = { + login: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), + title: z.string().min(1), + body: z.string().max(40000).optional(), + labels: z.array(z.string()).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(50).optional(), + changedPaths: z.array(z.string().min(1).max(400)).max(500).optional(), +}; + const preflightShape = { repoFullName: z.string().min(3), contributorLogin: z.string().min(1).optional(), @@ -646,6 +824,33 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of an issue from its title + body alone (no repo data) — flags clearly low-effort issues (empty body, an unfilled template) for triage. Returns slopRisk (0-100), band, findings, and the rubric. Advisory-only.", }, + // #6150 — the miner-auto-dev profile's plan-DAG + local-scorer + gate-prediction tools, previously listed in + // recommendedTools below but never actually registered. + { + name: "loopover_run_local_scorer", + category: "branch", + description: "Compute deterministic source/test/non-code token scores from local changed-file metadata + validation results — no repo/contributor access, reveals nothing beyond a computation on the caller's own diff stats. Pass the result as the localScorer field of loopover_preview_local_pr_score or the analyze tools to score this branch in external_command mode. Computed in-process; no API round-trip.", + }, + { + name: "loopover_build_plan", + category: "agent", + description: "Build a normalized step DAG (dependencies, retry limits) from a raw list of steps and validate it for cycles/unknown dependencies. Returns the plan, its progress, the currently-ready steps, and validation. Computed in-process; no API round-trip.", + }, + { + name: "loopover_plan_status", + category: "agent", + description: "Return a plan's current progress, the next ready steps, and validation status. Takes the plan object returned by loopover_build_plan or a prior loopover_record_step_result call. Computed in-process; no API round-trip.", + }, + { + name: "loopover_record_step_result", + category: "agent", + description: "Record the outcome (completed/failed/skipped) of a plan step the harness just ran and return the updated plan. A failed step retries (back to pending) until its maxAttempts is exhausted. Computed in-process; no API round-trip.", + }, + { + name: "loopover_predict_gate", + category: "review", + description: "Predict the LoopOver gate outcome for a planned PR before any local code exists — the same advisory + gate evaluation the maintainer pipeline runs, using only the repo's public .loopover.yml policy. Takes login, owner, repo, title, and optional body/labels/linkedIssues/changedPaths. Metadata-only, no source upload.", + }, { name: "loopover_preflight_local_diff", category: "branch", @@ -1146,6 +1351,75 @@ registerStdioTool( async (input) => toolResult("LoopOver issue-slop self-check.", await apiPost("/v1/lint/issue-slop", input)), ); +registerStdioTool( + "loopover_run_local_scorer", + { + description: stdioToolDescription("loopover_run_local_scorer"), + inputSchema: runLocalScorerShape, + }, + // Computed in-process from @loopover/engine (#6150) — matches the remote server's own + // computeLocalScorerTokens call (src/mcp/server.ts) with no API round-trip, so token scoring works fully + // offline. + (input) => toolResult("LoopOver local token scores.", computeLocalScorerTokens(input)), +); + +registerStdioTool( + "loopover_build_plan", + { + description: stdioToolDescription("loopover_build_plan"), + inputSchema: buildPlanShape, + }, + // Computed in-process (#6150) — matches the remote server's own buildPlanDag call (src/mcp/server.ts) + // with no API round-trip; the plan-DAG logic itself is hand-duplicated above (see its own comment). + (input) => toolResult("LoopOver plan built.", planView(buildPlanDag(input.steps))), +); + +registerStdioTool( + "loopover_plan_status", + { + description: stdioToolDescription("loopover_plan_status"), + inputSchema: planStatusShape, + }, + (input) => toolResult("LoopOver plan status.", planView(input.plan)), +); + +registerStdioTool( + "loopover_record_step_result", + { + description: stdioToolDescription("loopover_record_step_result"), + inputSchema: recordStepResultShape, + }, + (input) => + toolResult( + "LoopOver plan step result recorded.", + planView(applyStepResult(input.plan, input.stepId, { outcome: input.outcome, ...(input.error !== undefined ? { error: input.error } : {}) })), + ), +); + +registerStdioTool( + "loopover_predict_gate", + { + description: stdioToolDescription("loopover_predict_gate"), + inputSchema: predictGateShape, + }, + // Metadata-only proxy to the same route the branch-analysis tools already use (#6150) — that route computes + // predictedGate via buildPredictedGateVerdict (the identical logic the remote loopover_predict_gate tool + // uses) and returns it as a top-level field; no local git/workspace context is needed for this shape. + async (input) => { + const body = { + login: input.login, + repoFullName: `${input.owner}/${input.repo}`, + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.labels !== undefined ? { labels: input.labels } : {}), + ...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}), + ...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path) => ({ path })) } : {}), + }; + const result = await apiPost("/v1/local/branch-analysis", body); + return toolResult(`LoopOver predicted gate for ${input.owner}/${input.repo}.`, result.predictedGate); + }, +); + registerStdioTool( "loopover_preflight_local_diff", { diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 8212f31e95..c060692326 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2147,7 +2147,14 @@ describe("api routes", () => { it("serves installation repair diagnostics and refreshes installation health", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + LOOPOVER_DRIFT_ISSUE_REPO: "unrelated-org/unrelated-repo", + }); + // Isolate the first two /repair calls below from the real JSONbored/gittensory repo's live + // .loopover.yml -- this test's intent is pure DB-settings resolution. The later, more specific + // fetch stub (for the /refresh flow) replaces this one. + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); const repoPayload = { name: "gittensory", full_name: "JSONbored/gittensory", private: true, default_branch: "main", owner: { login: "JSONbored" } }; await upsertInstallation(env, { installation: { diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index e529780020..66a6fc3b5e 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -1173,7 +1173,7 @@ describe("GitHub backfill", () => { }); it("marks comment, label, and check repair impacts disabled by repo settings", async () => { - const env = createTestEnv(); + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: "unrelated-org/unrelated-repo" }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", @@ -1250,7 +1250,7 @@ describe("GitHub backfill", () => { }); it("repair diagnostics require contents:write for merge autonomy (#audit-install-health display)", async () => { - const env = createTestEnv(); + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: "unrelated-org/unrelated-repo" }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto" } }); // Force a deterministic 404 -- otherwise the manifest resolver's live fetch for "JSONbored/gittensory"'s diff --git a/test/unit/mcp-cli-plan-scorer-tools.test.ts b/test/unit/mcp-cli-plan-scorer-tools.test.ts new file mode 100644 index 0000000000..61844ae380 --- /dev/null +++ b/test/unit/mcp-cli-plan-scorer-tools.test.ts @@ -0,0 +1,269 @@ +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, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #6150: the miner-auto-dev profile's plan-DAG + local-scorer + gate-prediction tools were listed in +// recommendedTools but never actually registered on the local stdio server. These tests drive the real +// stdio server and assert each tool's composed output, plus a zod-rejection failure path per pure tool +// and an API-failure path for the one HTTP-backed tool (loopover_predict_gate). +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +const PLAN_SCORER_TOOLS = ["loopover_run_local_scorer", "loopover_build_plan", "loopover_plan_status", "loopover_record_step_result", "loopover_predict_gate"]; + +function structured(result: unknown): Record { + return (result as { structuredContent?: unknown }).structuredContent as Record; +} + +describe("loopover-mcp plan-DAG + local-scorer + predict-gate tools (#6150) — pure tools", () => { + let client: Client; + let transport: StdioClientTransport; + let configDir: string; + + async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-plan-scorer-tools-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + // These 4 pure tools never call the API, but the stdio server still needs a config dir + token to boot. + env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_TIMEOUT_MS: "5000" }, + }); + client = new Client({ name: "plan-scorer-tools-test", version: "0.0.1" }); + await client.connect(transport); + } + + afterEach(async () => { + await client?.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + }); + + it("registers all 5 tools on the local stdio server", async () => { + await connect(); + const names = new Set((await client.listTools()).tools.map((t) => t.name)); + for (const name of PLAN_SCORER_TOOLS) expect(names, `missing ${name}`).toContain(name); + }); + + it("loopover_run_local_scorer computes source/test/non-code token scores from changed-file metadata", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_run_local_scorer", + arguments: { + changedFiles: [ + { path: "src/cache.ts", additions: 12, deletions: 2 }, + { path: "test/cache.test.ts", additions: 8, deletions: 0 }, + { path: "README.md", additions: 3, deletions: 0 }, + ], + }, + }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + expect(data.mode).toBe("external_command"); + expect(data.sourceTokenScore).toBe(14); + expect(data.testTokenScore).toBe(8); + expect(data.nonCodeTokenScore).toBe(3); + expect(data.totalTokenScore).toBe(25); + }); + + it("loopover_run_local_scorer surfaces a validation-failure warning without changing the scores", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_run_local_scorer", + arguments: { + changedFiles: [{ path: "src/cache.ts", additions: 5, deletions: 0 }], + validation: [{ command: "npm test", status: "failed" }], + }, + }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + expect(data.sourceTokenScore).toBe(5); + expect(data.warnings).toEqual(["Local validation reported failures — token scores describe the diff, not a passing build."]); + }); + + it("loopover_run_local_scorer rejects an empty changedFiles array (zod input-schema validation)", async () => { + await connect(); + const outcome = await client.callTool({ name: "loopover_run_local_scorer", arguments: { changedFiles: [] } }).then( + (r) => ({ threw: false, isError: Boolean(r.isError) }), + () => ({ threw: true, isError: true }), + ); + expect(outcome.isError).toBe(true); + }); + + it("loopover_build_plan normalizes raw steps into a validated DAG with ready steps", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_build_plan", + arguments: { + steps: [ + { id: "a", title: "Step A" }, + { id: "b", title: "Step B", dependsOn: ["a"] }, + ], + }, + }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + const plan = data.plan as { steps: Array<{ id: string; status: string; attempts: number; maxAttempts: number }> }; + expect(plan.steps).toHaveLength(2); + expect(plan.steps[0]).toMatchObject({ id: "a", status: "pending", attempts: 0, maxAttempts: 1 }); + expect(data.readySteps).toEqual([{ id: "a", title: "Step A" }]); + expect((data.validation as { valid: boolean }).valid).toBe(true); + }); + + it("loopover_build_plan flags a dependency cycle as invalid, not a thrown error", async () => { + await connect(); + const result = await client.callTool({ + name: "loopover_build_plan", + arguments: { + steps: [ + { id: "a", title: "Step A", dependsOn: ["b"] }, + { id: "b", title: "Step B", dependsOn: ["a"] }, + ], + }, + }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + const validation = data.validation as { valid: boolean; errors: string[] }; + expect(validation.valid).toBe(false); + expect(validation.errors).toContain("plan has a dependency cycle"); + }); + + it("loopover_build_plan rejects an empty steps array (zod input-schema validation)", async () => { + await connect(); + const outcome = await client.callTool({ name: "loopover_build_plan", arguments: { steps: [] } }).then( + (r) => ({ threw: false, isError: Boolean(r.isError) }), + () => ({ threw: true, isError: true }), + ); + expect(outcome.isError).toBe(true); + }); + + it("loopover_plan_status returns progress + ready steps for an in-progress plan", async () => { + await connect(); + const plan = { + steps: [ + { id: "a", title: "Step A", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 1 }, + { id: "b", title: "Step B", dependsOn: ["a"], status: "pending", attempts: 0, maxAttempts: 1 }, + ], + }; + const result = await client.callTool({ name: "loopover_plan_status", arguments: { plan } }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + expect(data.progress).toMatchObject({ total: 2, completed: 1, pending: 1, status: "pending" }); + expect(data.readySteps).toEqual([{ id: "b", title: "Step B" }]); + }); + + it("loopover_plan_status rejects a plan with an unknown step status (zod input-schema validation)", async () => { + await connect(); + const badPlan = { steps: [{ id: "a", title: "Step A", dependsOn: [], status: "bogus", attempts: 0, maxAttempts: 1 }] }; + const outcome = await client.callTool({ name: "loopover_plan_status", arguments: { plan: badPlan } }).then( + (r) => ({ threw: false, isError: Boolean(r.isError) }), + () => ({ threw: true, isError: true }), + ); + expect(outcome.isError).toBe(true); + }); + + it("loopover_record_step_result records a completed step and advances readiness to the next step", async () => { + await connect(); + const plan = { + steps: [ + { id: "a", title: "Step A", dependsOn: [], status: "pending", attempts: 0, maxAttempts: 1 }, + { id: "b", title: "Step B", dependsOn: ["a"], status: "pending", attempts: 0, maxAttempts: 1 }, + ], + }; + const result = await client.callTool({ name: "loopover_record_step_result", arguments: { plan, stepId: "a", outcome: "completed" } }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + const updatedPlan = data.plan as { steps: Array<{ id: string; status: string }> }; + expect(updatedPlan.steps.find((s) => s.id === "a")?.status).toBe("completed"); + expect(data.readySteps).toEqual([{ id: "b", title: "Step B" }]); + }); + + it("loopover_record_step_result retries a failed step until maxAttempts is exhausted, then marks it failed", async () => { + await connect(); + const oneShotPlan = { steps: [{ id: "a", title: "Step A", dependsOn: [], status: "pending", attempts: 0, maxAttempts: 1 }] }; + const result = await client.callTool({ name: "loopover_record_step_result", arguments: { plan: oneShotPlan, stepId: "a", outcome: "failed", error: "boom" } }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + const updatedPlan = data.plan as { steps: Array<{ id: string; status: string; attempts: number; lastError: string | null }> }; + expect(updatedPlan.steps[0]).toMatchObject({ status: "failed", attempts: 1, lastError: "boom" }); + expect((data.progress as { status: string }).status).toBe("failed"); + }); + + it("loopover_record_step_result rejects an unknown outcome value (zod input-schema validation)", async () => { + await connect(); + const plan = { steps: [{ id: "a", title: "Step A", dependsOn: [], status: "pending", attempts: 0, maxAttempts: 1 }] }; + const outcome = await client.callTool({ name: "loopover_record_step_result", arguments: { plan, stepId: "a", outcome: "bogus" } }).then( + (r) => ({ threw: false, isError: Boolean(r.isError) }), + () => ({ threw: true, isError: true }), + ); + expect(outcome.isError).toBe(true); + }); +}); + +describe("loopover-mcp loopover_predict_gate (#6150) — HTTP-backed", () => { + let client: Client | null = null; + let transport: StdioClientTransport | null = null; + let configDir: string | null = null; + let capturedRequests: Array<{ url: string; method: string; body: unknown }>; + + async function connect(options: { localBranchAnalysisStatus?: number } = {}) { + configDir = mkdtempSync(join(tmpdir(), "loopover-predict-gate-")); + capturedRequests = []; + const apiUrl = await startFixtureServer({ + ...options, + onApiRequest: (request) => { + if (request.url === "/v1/local/branch-analysis") capturedRequests.push({ url: request.url, method: request.method ?? "POST", body: null }); + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_TIMEOUT_MS: "5000" }, + }); + client = new Client({ name: "predict-gate-test", version: "0.0.1" }); + await client.connect(transport); + } + + afterEach(async () => { + await client?.close().catch(() => undefined); + client = null; + transport = null; + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + configDir = null; + }); + + it("proxies to /v1/local/branch-analysis (metadata-only, no git context) and returns predictedGate", async () => { + await connect(); + const result = await client!.callTool({ + name: "loopover_predict_gate", + arguments: { login: "JSONbored", owner: "acme", repo: "widgets", title: "Add X", changedPaths: ["src/x.ts"] }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toHaveLength(1); + expect(capturedRequests[0]!.url).toBe("/v1/local/branch-analysis"); + expect(capturedRequests[0]!.method).toBe("POST"); + const data = structured(result); + expect(data).toMatchObject({ pack: "gittensor", conclusion: "advisory_pass", readinessScore: 72 }); + }); + + it("surfaces an API failure as a tool error", async () => { + await connect({ localBranchAnalysisStatus: 503 }); + const result = await client!.callTool({ + name: "loopover_predict_gate", + arguments: { login: "JSONbored", owner: "acme", repo: "widgets", title: "Add X" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/503/); + }); + + it("rejects a missing required field (zod input-schema validation)", async () => { + await connect(); + const outcome = await client!.callTool({ name: "loopover_predict_gate", arguments: { login: "JSONbored", owner: "acme", repo: "widgets" } }).then( + (r) => ({ threw: false, isError: Boolean(r.isError) }), + () => ({ threw: true, isError: true }), + ); + expect(outcome.isError).toBe(true); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index bba49561b4..c4c9d44921 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -4,6 +4,7 @@ // 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.) +// (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -47,14 +48,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 55 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 60 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(55); + expect(primary.length).toBe(60); expect(legacy.length).toBe(0); - expect(names.length).toBe(55); + expect(names.length).toBe(60); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -64,11 +65,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 55-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 60-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(55); + expect(payload.count).toBe(60); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/queue-5.test.ts b/test/unit/queue-5.test.ts index d097628a75..a8d633869c 100644 --- a/test/unit/queue-5.test.ts +++ b/test/unit/queue-5.test.ts @@ -2635,7 +2635,10 @@ describe("queue processors", () => { // (updatePullRequestSlopAssessment runs unconditionally inside shouldCollectSlopEvidence, independent of // the publish/comment pipeline), mirroring how "clears the persisted dashboard slop score when the slop // gate is off (#911)" above verifies the same live score. - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + LOOPOVER_DRIFT_ISSUE_REPO: "unrelated-org/unrelated-repo", + }); await persistRegistrySnapshot( env, normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index bab7050a22..826168c935 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -154,6 +154,7 @@ export async function startFixtureServer( onApiRequest?: (request: IncomingMessage) => void; validateConfigWarnings?: string[]; intakeStatus?: number; + localBranchAnalysisStatus?: number; } = {}, ) { server = createServer(async (request, response) => { @@ -283,6 +284,11 @@ export async function startFixtureServer( } if (request.url === "/v1/local/branch-analysis" && request.method === "POST") { await readJsonRequest(request); + if (options.localBranchAnalysisStatus && options.localBranchAnalysisStatus >= 400) { + response.statusCode = options.localBranchAnalysisStatus; + response.end(JSON.stringify({ error: "local_branch_analysis_unavailable" })); + return; + } response.end(JSON.stringify(options.localBranchAnalysis ?? localBranchAnalysisFixture())); return; } @@ -592,6 +598,15 @@ export function localBranchAnalysisFixture() { rerunWhen: "Rerun after account/queue maturity blockers clear.", }, dataQuality: { signalFidelity: { status: "complete" } }, + predictedGate: { + pack: "gittensor", + conclusion: "advisory_pass", + title: "Predicted gate: advisory pass", + summary: "No hard blockers predicted for this planned PR.", + readinessScore: 72, + blockers: [], + warnings: [], + }, }; }