Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
26 changes: 26 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions test/unit/mcp-cli-progress-snapshot-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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());
});
});
Expand Down
94 changes: 94 additions & 0 deletions test/unit/routes-progress-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});