diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 51f1154d40..d8db1d2de7 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -32,6 +32,8 @@ import { buildTestEvidenceReport } from "@loopover/engine/signals/test-evidence" import { evaluateEscalation } from "@loopover/engine"; // #6752: the same pure composer the remote MCP tool + /v1/loop/results-payload both call. import { buildResultsPayload } from "@loopover/engine"; +// #6753: the same pure composer the remote MCP tool + /v1/loop/progress-snapshot both call. +import { buildProgressSnapshot } from "@loopover/engine"; // #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call. import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine"; import { z } from "zod"; @@ -578,6 +580,19 @@ const resultsPayloadShape = { status: z.enum(["open", "merged", "closed"]).optional(), }; +// #6753: mirrors buildProgressSnapshotShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and +// the REST route all accept an identical payload. +const buildProgressSnapshotShape = { + iteration: z.number().int(), + maxIterations: z.number().int().nullable().optional(), + phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), + status: z.enum(["running", "converged", "abandoned", "error"]), + recentActivity: z + .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) + .max(1000) + .optional(), +}; + // #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality). const checkTestEvidenceShape = { changedPaths: z.array(z.string().min(1).max(400)).max(2000), @@ -975,6 +990,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Package a completed loop iteration into the customer-facing result (#4801): a PR link, a plain-language summary, and a bounded diff preview, from already-computed iteration metadata. Deterministic and source-free — it formats the result, it does not fetch, open, or deliver anything. Computed in-process; no API round-trip.", }, + { + name: "loopover_build_progress_snapshot", + category: "agent", + description: + "Build a near-real-time progress snapshot for a running rented loop (#4800): phase, status, iteration/percent-complete, and a bounded recent-activity tail, from already-computed loop state. Deterministic and source-free; a customer surface pushes it on change rather than polling on a fixed interval. Computed in-process; no API round-trip.", + }, { name: "loopover_intake_idea", category: "agent", @@ -1603,6 +1624,18 @@ registerStdioTool( (input) => toolResult("LoopOver loop results payload.", buildResultsPayload(input)), ); +registerStdioTool( + "loopover_build_progress_snapshot", + { + description: stdioToolDescription("loopover_build_progress_snapshot"), + inputSchema: buildProgressSnapshotShape, + }, + // Computed in-process from @loopover/engine (#6753) — the same pure buildProgressSnapshot the remote server + // (src/mcp/server.ts) and the /v1/loop/progress-snapshot route both call, so all three surfaces return an + // identical snapshot for identical input, and progress composition works fully offline. + (input) => toolResult("LoopOver loop progress snapshot.", buildProgressSnapshot(input)), +); + registerStdioTool( "loopover_intake_idea", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 22f558ff56..b734dd6cb7 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -203,6 +203,7 @@ import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } f import { buildTestEvidenceReport } from "../signals/test-evidence"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; +import { buildProgressSnapshot } from "../loop-progress"; import { validateIdeaSubmission, buildTaskGraph } from "../idea-intake"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { @@ -525,6 +526,19 @@ const resultsPayloadSchema = z.object({ status: z.enum(["open", "merged", "closed"]).optional(), }); +// #6753: mirrors buildProgressSnapshotShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the +// REST surface can never accept an input the MCP tool would reject, or vice versa. +const progressSnapshotSchema = z.object({ + iteration: z.number().int(), + maxIterations: z.number().int().nullable().optional(), + phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), + status: z.enum(["running", "converged", "abandoned", "error"]), + recentActivity: z + .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) + .max(1000) + .optional(), +}); + // #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the // REST surface can never accept an input the MCP tool would reject, or vice versa. const testEvidenceSchema = z.object({ @@ -3351,6 +3365,18 @@ export function createApp() { return c.json(buildResultsPayload(parsed.data)); }); + // #6753: REST mirror of the loopover_build_progress_snapshot MCP tool, bringing it to the same REST/CLI parity + // its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Both are pure, source-free + // composers over caller-supplied, already-computed loop state, so this route delegates to the same + // buildProgressSnapshot the tool calls and adds no logic of its own -- it formats the snapshot, it does not + // fetch or stream anything. + app.post("/v1/loop/progress-snapshot", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = progressSnapshotSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_progress_snapshot_request", issues: parsed.error.issues }, 400); + return c.json(buildProgressSnapshot(parsed.data)); + }); + // #6755: REST mirror of the loopover_intake_idea MCP tool, bringing it to the same REST/CLI parity its // same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Reproduces the tool's handler // exactly -- validate, then assemble the task-graph from the optional caller-supplied decomposition (else the diff --git a/test/unit/mcp-cli-progress-snapshot-tool.test.ts b/test/unit/mcp-cli-progress-snapshot-tool.test.ts new file mode 100644 index 0000000000..5055eac3f4 --- /dev/null +++ b/test/unit/mcp-cli-progress-snapshot-tool.test.ts @@ -0,0 +1,88 @@ +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 { buildProgressSnapshot, type LoopProgressState } from "../../src/loop-progress"; + +// #6753: the local mirror of loopover_build_progress_snapshot. Like its same-tier sibling +// loopover_check_slop_risk, it computes IN-PROCESS from @loopover/engine — no API round-trip — so +// progress composition works fully offline. The point of these tests is cross-surface PARITY: the +// stdio tool must return exactly what the pure buildProgressSnapshot returns for identical input +// (the same function /v1/loop/progress-snapshot 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-progress-snapshot-")); + 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: "progress-snapshot-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_build_progress_snapshot stdio mirror (#6753)", () => { + 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_build_progress_snapshot"); + expect(names).toContain("loopover_check_slop_risk"); + }); + + it("matches the pure builder for representative states — offline, with no API reachable", async () => { + const cases: LoopProgressState[] = [ + { iteration: 0, phase: "queued", status: "running" }, + { iteration: 2, maxIterations: 5, phase: "coding", status: "running" }, + { iteration: 5, maxIterations: 5, phase: "done", status: "converged" }, + { iteration: 1, maxIterations: null, phase: "reviewing", status: "error" }, + { + iteration: 3, + maxIterations: 10, + phase: "submitting", + status: "abandoned", + recentActivity: [{ step: "plan" }, { step: "code", detail: "wrote tests", at: "2026-07-17T00:00:00.000Z" }], + }, + ]; + for (const args of cases) { + const result = await client.callTool({ name: "loopover_build_progress_snapshot", 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(buildProgressSnapshot(args))), + ); + } + }); + + it("rejects invalid input (zod input-schema validation)", async () => { + for (const args of [ + {}, + { iteration: 1, phase: "coding" }, + { iteration: 1, phase: "bogus", status: "running" }, + { iteration: 1.5, phase: "coding", status: "running" }, + ]) { + const rejected = await client.callTool({ name: "loopover_build_progress_snapshot", 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 3f7a6fa988..cb74d301b5 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -13,6 +13,7 @@ // (#6752 registered the loopover_build_results_payload CLI mirror, taking the count from 67 to 68.) // (#6755 registered the loopover_intake_idea CLI mirror, taking the count from 68 to 69.) // (#6915 registered the loopover_simulate_open_pr_pressure CLI mirror, taking the count from 69 to 70.) +// (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -56,14 +57,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 70 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 71 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(70); + expect(primary.length).toBe(71); expect(legacy.length).toBe(0); - expect(names.length).toBe(70); + expect(names.length).toBe(71); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -73,11 +74,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 70-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 71-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(70); + expect(payload.count).toBe(71); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-progress-snapshot.test.ts b/test/unit/routes-progress-snapshot.test.ts new file mode 100644 index 0000000000..af8aa78960 --- /dev/null +++ b/test/unit/routes-progress-snapshot.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildProgressSnapshot, type LoopProgressState } from "../../src/loop-progress"; +import { createTestEnv } from "../helpers/d1"; + +// #6753: POST /v1/loop/progress-snapshot — the REST mirror bringing loopover_build_progress_snapshot to the +// same parity its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. The route +// delegates to the pure buildProgressSnapshot (covered by its own unit tests), so these pin the ROUTE +// contract: the snapshot is returned unmodified, and a bad body is rejected. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); +const PATH = "/v1/loop/progress-snapshot"; + +const post = (env: Env, body: unknown) => + createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +describe("POST /v1/loop/progress-snapshot (#6753)", () => { + it("returns a progress snapshot for a healthy mid-run loop", async () => { + const env = createTestEnv(); + const body = { + iteration: 2, + maxIterations: 5, + phase: "coding", + status: "running", + recentActivity: [{ step: "edit", detail: "touched routes.ts" }], + } satisfies LoopProgressState; + const response = await post(env, body); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + phase: "coding", + status: "running", + iteration: 2, + maxIterations: 5, + percentComplete: 40, + done: false, + }); + }); + + it("matches the pure builder for every representative state — parity with the MCP tool", async () => { + const env = createTestEnv(); + const cases: LoopProgressState[] = [ + { iteration: 0, phase: "queued", status: "running" }, + { iteration: 1, maxIterations: 4, phase: "claiming", status: "running" }, + { iteration: 3, maxIterations: 3, phase: "done", status: "converged" }, + { iteration: 1, maxIterations: null, phase: "reviewing", status: "error" }, + { + iteration: 2, + maxIterations: 10, + phase: "submitting", + status: "abandoned", + recentActivity: [ + { step: "plan", at: "2026-07-17T00:00:00.000Z" }, + { step: "code", detail: "wrote tests" }, + ], + }, + ]; + 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 builder the MCP tool calls returns. + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(buildProgressSnapshot(body)))); + } + }); + + it("rejects an invalid or unparseable body with 400", async () => { + const env = createTestEnv(); + for (const body of [ + {}, + { iteration: 1, phase: "coding" }, + { iteration: 1, phase: "bogus", status: "running" }, + { iteration: 1.5, phase: "coding", status: "running" }, + { iteration: 1, phase: "coding", status: "running", recentActivity: "nope" }, + ]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_progress_snapshot_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, { iteration: 1, maxIterations: 2, phase: "coding", status: "running" }) + ).json(), + ); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +});