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
28 changes: 27 additions & 1 deletion packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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 <github-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 <github-login> or set LOOPOVER_LOGIN.");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -5362,6 +5387,7 @@ Source upload remains disabled.

function printAgentHelp() {
process.stdout.write(`Usage:
loopover-mcp agent start --login <github-login> --objective "..." [--repo owner/repo] [--pull <n>] [--issue <n>] [--json]
loopover-mcp agent plan --login <github-login> [--repo owner/repo] [--objective "..."] [--json]
loopover-mcp agent status <run-id> [--json]
loopover-mcp agent explain <run-id> [--json]
Expand Down
119 changes: 119 additions & 0 deletions test/unit/mcp-cli-agent-start.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };

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<void>): Promise<string> {
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 "..."');
});
});
9 changes: 9 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export async function startFixtureServer(
/** #6980: overrides POST /v1/preflight/review-risk and captures the request body. */
reviewRisk?: Record<string, unknown>;
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<string, unknown>;
notificationsRead?: Record<string, unknown>;
Expand Down Expand Up @@ -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;
Expand Down