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
9 changes: 9 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,15 @@ export {
type TaskGraphIssueScore,
type TaskGraphScore,
} from "./idea-intake.js";
export {
buildResultsPayload,
MAX_DIFF_PREVIEW_FILES,
type DiffPreviewFile,
type IterationResult,
type LoopResultStatus,
type ResultChangedFile,
type ResultsPayload,
} from "./results-payload.js";
export {
buildMetadataRankInput,
computeMetadataDupRisk,
Expand Down
64 changes: 64 additions & 0 deletions packages/loopover-engine/src/results-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Loop results-delivery composer (pure) — packages a completed loop iteration into the customer-facing
// result: a PR link, a plain-language summary, and a bounded diff preview (#4801, part of the Rent-a-Loop
// path #4778). Deterministic and side-effect-free: a plain in/out transform over already-computed iteration
// metadata (no IO, no GitHub calls), mirroring the intake bridge (#4798) at the other end of the loop.

// Cap the preview so a large change never floods the customer surface; the totals below still count every file.
export const MAX_DIFF_PREVIEW_FILES = 10;

export type LoopResultStatus = "open" | "merged" | "closed";

export type ResultChangedFile = {
path: string;
additions?: number | undefined;
deletions?: number | undefined;
};

/** The already-computed outcome of one completed loop iteration. */
export type IterationResult = {
repoFullName: string;
/** The opened pull request's number, or null/absent when the iteration produced no PR. */
prNumber?: number | null | undefined;
title: string;
changedFiles?: ResultChangedFile[] | undefined;
status?: LoopResultStatus | undefined;
};

export type DiffPreviewFile = { path: string; additions: number; deletions: number };

export type ResultsPayload = {
/** Canonical PR URL, or null when no PR was opened. */
prLink: string | null;
/** One readable, public-safe sentence a customer can act on without assembling anything. */
summary: string;
/** Up to {@link MAX_DIFF_PREVIEW_FILES} changed files; `totals` still reflects the full change. */
diffPreview: DiffPreviewFile[];
totals: { files: number; additions: number; deletions: number };
};

/** Package a completed iteration into the customer-facing results payload (#4801). Pure: it formats
* already-fetched iteration metadata, it does not fetch, open, or deliver anything. */
export function buildResultsPayload(result: IterationResult): ResultsPayload {
const normalized: DiffPreviewFile[] = (result.changedFiles ?? []).map((f) => ({
path: f.path,
additions: f.additions ?? 0,
deletions: f.deletions ?? 0,
}));
const totals = normalized.reduce(
(acc, f) => ({ files: acc.files + 1, additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }),
{ files: 0, additions: 0, deletions: 0 },
);

const hasPr = result.prNumber !== null && result.prNumber !== undefined;
const prLink = hasPr ? `https://github.com/${result.repoFullName}/pull/${result.prNumber}` : null;
const status: LoopResultStatus = result.status ?? "open";

const prPart = hasPr ? `Opened PR #${result.prNumber} in ${result.repoFullName}` : `No pull request was opened for ${result.repoFullName}`;
const changePart =
totals.files === 0
? "no file changes"
: `${totals.files} file${totals.files === 1 ? "" : "s"} changed (+${totals.additions} / -${totals.deletions})`;
const summary = `${prPart}: ${result.title}. ${changePart}. Status: ${status}.`;

return { prLink, summary, diffPreview: normalized.slice(0, MAX_DIFF_PREVIEW_FILES), totals };
}
40 changes: 40 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/p
import { buildIssueSlopAssessment } from "../signals/issue-slop";
import { buildSlopAssessment } from "../signals/slop";
import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake";
import { buildResultsPayload } from "../results-payload";
import { buildStructuralImprovementAssessment } from "../signals/improvement";
import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation";
import { buildRepoDataQuality } from "../signals/data-quality";
Expand Down Expand Up @@ -956,6 +957,25 @@ const planIdeaClaimsOutputSchema = {
errors: z.array(z.string()).optional(),
};

// Loop results-delivery input (#4801): a completed iteration's already-computed metadata.
const buildResultsPayloadShape = {
repoFullName: z.string().min(1),
prNumber: z.number().int().nullable().optional(),
title: z.string(),
changedFiles: z
.array(z.object({ path: z.string(), additions: z.number().int().optional(), deletions: z.number().int().optional() }))
.max(5000)
.optional(),
status: z.enum(["open", "merged", "closed"]).optional(),
};

const buildResultsPayloadOutputSchema = {
prLink: z.string().nullable().optional(),
summary: z.string().optional(),
diffPreview: z.unknown().optional(),
totals: z.unknown().optional(),
};

// Deterministic structural-improvement counterpart to checkSlopRiskShape (#4746, sub-issue I of epic #4737):
// the positive-axis mirror of checkSlopRisk, same pure local-metadata contract. changedFiles/tests/testFiles
// are reused verbatim (same shape as checkSlopRiskShape) so the two signals never disagree about what counts
Expand Down Expand Up @@ -1728,6 +1748,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.planIdeaClaims(input)),
);

server.registerTool(
"loopover_build_results_payload",
{
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.",
inputSchema: buildResultsPayloadShape,
outputSchema: buildResultsPayloadOutputSchema,
},
async (input) => this.toolResult(await this.buildLoopResults(input)),
);

server.registerTool(
"loopover_check_slop_risk",
{
Expand Down Expand Up @@ -3032,6 +3063,15 @@ export class LoopoverMcp {
};
}

private async buildLoopResults(input: z.infer<z.ZodObject<typeof buildResultsPayloadShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_build_results_payload");
const payload = buildResultsPayload(input);
return {
summary: payload.summary,
data: payload as unknown as Record<string, unknown>,
};
}

private async checkSlopRisk(input: z.infer<z.ZodObject<typeof checkSlopRiskShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_check_slop_risk");
const assessment = buildSlopAssessment(input);
Expand Down
5 changes: 5 additions & 0 deletions src/results-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Loop results-delivery composer (#4801) — thin re-export shim. The canonical implementation lives in
// `@loopover/engine` (packages/loopover-engine/src/results-payload.ts), imported via the relative source
// path (matching src/idea-intake.ts / src/signals/slop.ts) so the published loopover-mcp / loopover-miner
// CLIs share one composer, and so this never depends on the engine's built dist/ during typecheck/test.
export * from "../packages/loopover-engine/src/results-payload";
47 changes: 47 additions & 0 deletions test/unit/mcp-results-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { LoopoverMcp } from "../../src/mcp/server";
import { createTestEnv } from "../helpers/d1";

async function connect() {
const server = new LoopoverMcp(createTestEnv()).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "gittensory-results-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return client;
}

describe("MCP loopover_build_results_payload", () => {
it("packages a completed iteration into a PR link, summary, and diff preview", async () => {
const client = await connect();
const result = await client.callTool({
name: "loopover_build_results_payload",
arguments: {
repoFullName: "acme/widgets",
prNumber: 42,
title: "Add retry to uploads",
changedFiles: [{ path: "src/upload.ts", additions: 12, deletions: 2 }],
status: "open",
},
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as { prLink: string; summary: string; diffPreview: unknown[]; totals: { files: number } };
expect(data.prLink).toBe("https://github.com/acme/widgets/pull/42");
expect(data.summary).toContain("Opened PR #42 in acme/widgets");
expect(data.diffPreview).toHaveLength(1);
expect(data.totals.files).toBe(1);
});

it("reports no PR when prNumber is null", async () => {
const client = await connect();
const result = await client.callTool({
name: "loopover_build_results_payload",
arguments: { repoFullName: "acme/widgets", prNumber: null, title: "No PR produced" },
});
const data = result.structuredContent as { prLink: string | null; summary: string };
expect(data.prLink).toBeNull();
expect(data.summary).toContain("No pull request was opened");
});
});
58 changes: 58 additions & 0 deletions test/unit/results-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { buildResultsPayload, MAX_DIFF_PREVIEW_FILES, type IterationResult } from "../../packages/loopover-engine/src/results-payload";

describe("buildResultsPayload — packages a completed loop iteration (#4801)", () => {
it("builds a PR link, plain-language summary, and diff preview for a completed PR", () => {
const r: IterationResult = {
repoFullName: "acme/widgets",
prNumber: 12,
title: "Uploads should retry on 5xx",
changedFiles: [
{ path: "src/upload.ts", additions: 30, deletions: 4 },
{ path: "test/upload.test.ts", additions: 10, deletions: 1 },
],
status: "merged",
};
const p = buildResultsPayload(r);
expect(p.prLink).toBe("https://github.com/acme/widgets/pull/12");
expect(p.summary).toBe("Opened PR #12 in acme/widgets: Uploads should retry on 5xx. 2 files changed (+40 / -5). Status: merged.");
expect(p.diffPreview).toEqual([
{ path: "src/upload.ts", additions: 30, deletions: 4 },
{ path: "test/upload.test.ts", additions: 10, deletions: 1 },
]);
expect(p.totals).toEqual({ files: 2, additions: 40, deletions: 5 });
});

it("reports no PR (and defaults status to open) when prNumber is null", () => {
const p = buildResultsPayload({ repoFullName: "acme/widgets", prNumber: null, title: "Attempted change", changedFiles: [{ path: "a.ts" }] });
expect(p.prLink).toBeNull();
expect(p.summary).toBe("No pull request was opened for acme/widgets: Attempted change. 1 file changed (+0 / -0). Status: open.");
});

it("treats an omitted prNumber the same as null", () => {
const p = buildResultsPayload({ repoFullName: "acme/widgets", title: "No PR", changedFiles: [] });
expect(p.prLink).toBeNull();
// omitted changedFiles + empty array both yield "no file changes"
expect(p.summary).toBe("No pull request was opened for acme/widgets: No PR. no file changes. Status: open.");
});

it("says 'no file changes' when changedFiles is omitted entirely", () => {
const p = buildResultsPayload({ repoFullName: "o/r", prNumber: 3, title: "Docs" });
expect(p.totals).toEqual({ files: 0, additions: 0, deletions: 0 });
expect(p.diffPreview).toEqual([]);
expect(p.summary).toContain("no file changes");
});

it("defaults missing per-file additions/deletions to zero", () => {
const p = buildResultsPayload({ repoFullName: "o/r", prNumber: 1, title: "t", changedFiles: [{ path: "x.ts" }] });
expect(p.diffPreview).toEqual([{ path: "x.ts", additions: 0, deletions: 0 }]);
});

it("caps the diff preview at MAX_DIFF_PREVIEW_FILES while totals still count every file", () => {
const files = Array.from({ length: MAX_DIFF_PREVIEW_FILES + 3 }, (_, i) => ({ path: `f${i}.ts`, additions: 1, deletions: 1 }));
const p = buildResultsPayload({ repoFullName: "o/r", prNumber: 9, title: "Big change", changedFiles: files });
expect(p.diffPreview).toHaveLength(MAX_DIFF_PREVIEW_FILES);
expect(p.totals.files).toBe(MAX_DIFF_PREVIEW_FILES + 3);
expect(p.summary).toContain(`${MAX_DIFF_PREVIEW_FILES + 3} files changed`); // plural
});
});