From 4fe77eb39d4d193caf77538797f8ea4ee34732d3 Mon Sep 17 00:00:00 2001 From: michiot05 <281539540+michiot05@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:19:11 +0200 Subject: [PATCH] feat(mcp): add loopover-mcp agent start CLI subcommand Closes #8314. Adds a start branch to runAgentCli mirroring the loopover_agent_start_run stdio tool's POST /v1/agent/runs request shape (objective/actorLogin required, --repo/--pull/--issue optional target), with surface "cli" so the CLI and MCP entry points stay distinguishable. Registers start in CLI_COMMAND_SPEC.agent and printAgentHelp. Exports runAgentCli via a separate statement (an inline export prefix breaks the spec-parity boundary regex) for in-process test coverage; adds a captured POST /v1/agent/runs harness route and full-branch CLI tests. --- packages/loopover-mcp/bin/loopover-mcp.ts | 28 ++++- test/unit/mcp-cli-agent-start.test.ts | 119 ++++++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 9 ++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 test/unit/mcp-cli-agent-start.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 768eb26884..8cd38e0609 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -128,7 +128,7 @@ const CLI_COMMAND_SPEC = { "issue-slop": [], profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], - agent: ["plan", "status", "explain", "packet"], + agent: ["start", "plan", "status", "explain", "packet"], maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "selftune-audit", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; @@ -4899,6 +4899,26 @@ async function runAgentCli(args: any) { const subcommand = args[0] ?? "help"; if (subcommand === "--help" || subcommand === "help") return printAgentHelp(); const options = parseOptions(args.slice(1)); + if (subcommand === "start") { + // #8314: the CLI-typable counterpart to the loopover_agent_start_run stdio tool -- POSTs the same + // /v1/agent/runs request shape. `objective` and `actorLogin` are non-optional in agentRunShape + // (src/mcp/server.ts), so enforce both here, resolving --login exactly as plan/packet do. surface is "cli" + // (not the stdio tool's "mcp") so the two entry points stay distinguishable server-side, per the issue. + const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); + if (!options.objective || options.objective === true) throw new Error('Pass --objective "..." to describe the run.'); + const payload = await apiPost("/v1/agent/runs", { + objective: options.objective, + actorLogin: login, + surface: "cli", + target: stripUndefined({ + repoFullName: options.repo, + pullNumber: optionalInteger(options.pull), + issueNumber: optionalInteger(Array.isArray(options.issue) ? options.issue[0] : options.issue), + }), + }); + return outputAgentPayload(payload, options, `Queued LoopOver base-agent run for ${login}.`); + } if (subcommand === "plan") { const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); @@ -4944,6 +4964,11 @@ async function runAgentCli(args: any) { } throw new Error(`Unknown agent command: ${subcommand}`); } +// Exported (as a separate statement, not an inline `export async function`, so the CLI_COMMAND_SPEC↔handler +// parity test's `\n(?:async )?function ` boundary regex still delimits this handler correctly) so an in-process +// unit test can call it directly for v8/Codecov coverage of the `agent start` branch -- a subprocess-spawned +// CLI run is invisible to coverage. Same rationale as maintainCli's own export. (#8314) +export { runAgentCli }; function outputAgentPayload(payload: any, options: any, summary: any) { if (options.json) { @@ -5362,6 +5387,7 @@ Source upload remains disabled. function printAgentHelp() { process.stdout.write(`Usage: + loopover-mcp agent start --login --objective "..." [--repo owner/repo] [--pull ] [--issue ] [--json] loopover-mcp agent plan --login [--repo owner/repo] [--objective "..."] [--json] loopover-mcp agent status [--json] loopover-mcp agent explain [--json] diff --git a/test/unit/mcp-cli-agent-start.test.ts b/test/unit/mcp-cli-agent-start.test.ts new file mode 100644 index 0000000000..ac1c1dbc54 --- /dev/null +++ b/test/unit/mcp-cli-agent-start.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #8314: in-process coverage for the `agent start` CLI subcommand in packages/loopover-mcp/bin/loopover-mcp.ts. +// Same #7764 entrypoint-guard pattern as mcp-cli-selftune-audit — import the committed .ts and call the +// exported runAgentCli directly, so v8/Codecov attributes the new branch (a subprocess-spawned CLI run is +// invisible to coverage). +const MODULE = "../../packages/loopover-mcp/bin/loopover-mcp.ts"; + +type BinModule = { runAgentCli: (args: string[]) => Promise }; + +let tempDir = ""; +let mod: BinModule; +const capturedBodies: unknown[] = []; +let savedLoopoverLogin: string | undefined; +let savedGithubLogin: string | undefined; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-agent-start-")); + const apiUrl = await startFixtureServer({ onAgentRunRequest: (body) => capturedBodies.push(body) }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + // The login-fallback reads these; unset them so the missing-login case throws deterministically. + savedLoopoverLogin = process.env.LOOPOVER_LOGIN; + savedGithubLogin = process.env.GITHUB_LOGIN; + delete process.env.LOOPOVER_LOGIN; + delete process.env.GITHUB_LOGIN; + mod = (await import(MODULE)) as unknown as BinModule; +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + for (const key of ["LOOPOVER_API_URL", "LOOPOVER_API_TOKEN", "LOOPOVER_API_TIMEOUT_MS", "LOOPOVER_CONFIG_DIR", "LOOPOVER_SKIP_NPM_VERSION_CHECK"]) delete process.env[key]; + if (savedLoopoverLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLoopoverLogin; + if (savedGithubLogin !== undefined) process.env.GITHUB_LOGIN = savedGithubLogin; +}); + +beforeEach(() => { + capturedBodies.length = 0; +}); + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(""); +} + +describe("bin agent start CLI (in-process, #8314)", () => { + it("posts the full run request with surface 'cli' and every target field mapped", async () => { + await captureStdout(() => + mod.runAgentCli(["start", "--login", "octominer", "--objective", "Fix the flaky retry", "--repo", "acme/widgets", "--pull", "42", "--issue", "7"]), + ); + expect(capturedBodies).toHaveLength(1); + expect(capturedBodies[0]).toEqual({ + objective: "Fix the flaky retry", + actorLogin: "octominer", + surface: "cli", + target: { repoFullName: "acme/widgets", pullNumber: 42, issueNumber: 7 }, + }); + }); + + it("omits absent target fields entirely (stripUndefined leaves an empty target)", async () => { + await captureStdout(() => mod.runAgentCli(["start", "--login", "octominer", "--objective", "Just start"])); + expect(capturedBodies[0]).toEqual({ + objective: "Just start", + actorLogin: "octominer", + surface: "cli", + target: {}, + }); + }); + + it("emits the raw API payload under --json", async () => { + const out = await captureStdout(() => mod.runAgentCli(["start", "--login", "octominer", "--objective", "Ship it", "--json"])); + const payload = JSON.parse(out); + expect(payload).toBeTypeOf("object"); + expect(capturedBodies[0]).toMatchObject({ actorLogin: "octominer", surface: "cli" }); + }); + + it("throws a usage error when --login is missing (and no LOOPOVER_LOGIN/GITHUB_LOGIN)", async () => { + await expect(mod.runAgentCli(["start", "--objective", "no login"])).rejects.toThrow(/Pass --login/); + expect(capturedBodies).toHaveLength(0); + }); + + it("throws a usage error when --objective is missing", async () => { + await expect(mod.runAgentCli(["start", "--login", "octominer"])).rejects.toThrow(/Pass --objective/); + expect(capturedBodies).toHaveLength(0); + }); + + it("throws a usage error when --objective is passed without a value", async () => { + await expect(mod.runAgentCli(["start", "--login", "octominer", "--objective"])).rejects.toThrow(/Pass --objective/); + expect(capturedBodies).toHaveLength(0); + }); + + it("falls through the start check to the unknown-subcommand error for a non-start subcommand", async () => { + await expect(mod.runAgentCli(["bogus"])).rejects.toThrow(/Unknown agent command: bogus/); + expect(capturedBodies).toHaveLength(0); + }); + + it("documents `agent start` in the agent help output", async () => { + const out = await captureStdout(() => mod.runAgentCli(["--help"])); + expect(out).toContain("loopover-mcp agent start --login"); + expect(out).toContain('--objective "..."'); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 25d1150f70..ff331d0f2b 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -196,6 +196,8 @@ export async function startFixtureServer( /** #6980: overrides POST /v1/preflight/review-risk and captures the request body. */ reviewRisk?: Record; onReviewRiskRequest?: (body: unknown) => void; + /** #8314: captures the POST /v1/agent/runs request body (the `agent start` CLI create request). */ + onAgentRunRequest?: (body: unknown) => void; /** #6745: overrides the notification feed / mark-read responses, and captures the mark-read POST body. */ notifications?: Record; notificationsRead?: Record; @@ -392,6 +394,13 @@ export async function startFixtureServer( response.end(JSON.stringify(agentFixture())); return; } + // #8314: POST /v1/agent/runs — the base-agent run-create endpoint the `agent start` CLI (and the + // loopover_agent_start_run stdio tool) posts to; captures the request body for assertion. + if (request.url === "/v1/agent/runs" && request.method === "POST") { + options.onAgentRunRequest?.(await readJsonRequest(request)); + response.end(JSON.stringify(agentFixture())); + return; + } if (request.url === "/v1/agent/runs/run-1" && request.method === "GET") { response.end(JSON.stringify(agentFixture())); return;