diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 128cab2ec3..1e7ebe0d83 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -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, diff --git a/packages/loopover-engine/src/results-payload.ts b/packages/loopover-engine/src/results-payload.ts new file mode 100644 index 0000000000..5456152649 --- /dev/null +++ b/packages/loopover-engine/src/results-payload.ts @@ -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 }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5521061d7b..6b0c69514b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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"; @@ -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 @@ -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", { @@ -3032,6 +3063,15 @@ export class LoopoverMcp { }; } + private async buildLoopResults(input: z.infer>): Promise { + await this.enforceToolRateLimit("loopover_build_results_payload"); + const payload = buildResultsPayload(input); + return { + summary: payload.summary, + data: payload as unknown as Record, + }; + } + private async checkSlopRisk(input: z.infer>): Promise { await this.enforceToolRateLimit("loopover_check_slop_risk"); const assessment = buildSlopAssessment(input); diff --git a/src/results-payload.ts b/src/results-payload.ts new file mode 100644 index 0000000000..e0ad84665a --- /dev/null +++ b/src/results-payload.ts @@ -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"; diff --git a/test/unit/mcp-results-payload.test.ts b/test/unit/mcp-results-payload.test.ts new file mode 100644 index 0000000000..8d08f7b1d8 --- /dev/null +++ b/test/unit/mcp-results-payload.test.ts @@ -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"); + }); +}); diff --git a/test/unit/results-payload.test.ts b/test/unit/results-payload.test.ts new file mode 100644 index 0000000000..cbc323e83a --- /dev/null +++ b/test/unit/results-payload.test.ts @@ -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 + }); +});