Files
openrig/packages/cli/test/acp-launcher.test.ts
T
dev-driver a31cb3cb7a fix(cli): the ACP launcher warns about another CLI version and runs it
- When module resolution finds an @openrig/cli other than the version
  @openrig/acp pins (only in a broken or pruned install), the launcher
  names the version it found, where it found it and the pinned version
  on stderr, then runs it as usual. A newer CLI very likely works, and
  one that doesn't fails visibly, so refusing protected nothing.
- The guide and the launcher's README give the full registration
  command, with the two options it needs.
- A reopened session replays its questions in the order they were
  asked. Cells now cover a question still open and one closed without
  an answer.
2026-11-07 14:19:10 +00:00

147 lines
8.3 KiB
TypeScript

// @openrig/acp: the one-executable launcher the ACP registry starts with npx. It must stay in lockstep with the CLI it
// pins (a release that bumps one bumps all of them), pass its listing's checks, and hand the app's stdio, arguments,
// environment, working directory, exit code and signals straight to `rig acp serve`.
import { afterEach, describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { ConfigStore } from "../src/config-store.js";
import { acpReceiver } from "../src/commands/acp.js";
const repo = fileURLToPath(new URL("../../../", import.meta.url));
const json = (path: string) => JSON.parse(readFileSync(join(repo, path), "utf8"));
const cli = json("packages/cli/package.json");
const launcher = json("packages/acp/package.json");
const listing = json("packages/acp/registry/agent.json");
describe("@openrig/acp stays in lockstep with @openrig/cli", () => {
it("has the CLI's version, pins exactly that CLI, and has one executable", () => {
expect(launcher.name).toBe("@openrig/acp");
expect(launcher.version).toBe(cli.version);
expect(launcher.dependencies).toEqual({ "@openrig/cli": cli.version });
expect(launcher.bin).toEqual({ "openrig-acp": "bin/openrig-acp.js" });
expect(statSync(join(repo, "packages/acp/bin/openrig-acp.js")).mode & 0o111).not.toBe(0);
});
it("the registry listing names this version of the launcher, with no arguments", () => {
expect(listing).toMatchObject({ id: "openrig", name: "OpenRig", version: cli.version, license: "Apache-2.0" });
expect(listing.distribution).toEqual({ npx: { package: `@openrig/acp@${cli.version}` } });
});
it("the icon passes the registry's checks: 16x16, currentColor only, no comments", () => {
const svg = readFileSync(join(repo, "packages/acp/registry/icon.svg"), "utf8");
expect(svg).not.toContain("<!--");
const root = svg.match(/^<svg\b[^>]*>/)![0];
expect(root).toMatch(/\swidth="16"/);
expect(root).toMatch(/\sheight="16"/);
const paints = [...svg.matchAll(/\s(?:fill|stroke)="([^"]*)"/g)].map((m) => m[1]!.toLowerCase());
expect(paints).toContain("currentcolor");
expect(paints.every((v) => ["currentcolor", "none", "inherit"].includes(v))).toBe(true);
expect(svg).not.toMatch(/style=|<style/);
});
});
describe("acp.to: the receiver a registry launch uses", () => {
it("is empty by default, takes member@rig, and refuses anything else", () => {
const dir = mkdtempSync(join(tmpdir(), "acp-to-"));
try {
const store = new ConfigStore(join(dir, "config.json"));
expect(store.get("acp.to")).toBe("");
for (const bad of ["lead", "lead@", "@team", "lead @team", "a@b@c"]) expect(() => store.set("acp.to", bad)).toThrow(/Invalid acp\.to/);
store.set("acp.to", "lead@my-team");
expect(store.resolve().acp).toEqual({ to: "lead@my-team" });
store.set("acp.to", "");
expect(store.get("acp.to")).toBe("");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
it("--to wins; a settings file that can't be read is the reason the app's sign-in step shows", () => {
const dir = mkdtempSync(join(tmpdir(), "acp-to-"));
try {
const store = new ConfigStore(join(dir, "config.json"));
expect(acpReceiver(undefined, store)).toEqual({ receiver: undefined });
store.set("acp.to", "lead@my-team");
expect(acpReceiver(undefined, store)).toEqual({ receiver: "lead@my-team" });
writeFileSync(join(dir, "config.json"), "{not json");
expect(acpReceiver(" other@team ", store)).toEqual({ receiver: "other@team" });
const unreadable = acpReceiver(undefined, store);
expect("error" in unreadable && unreadable.error).toMatch(/malformed[\s\S]*adding --to <member@rig> to the agent's command skips it\.$/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe("openrig-acp hands everything to rig acp serve", () => {
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
/** The launcher installed beside a stand-in @openrig/cli (the version it pins, unless given) whose `rig` reports what it was given. */
function install(withCli = true, cliVersion: string = cli.version): string {
const root = mkdtempSync(join(tmpdir(), "openrig-acp-"));
dirs.push(root);
const acp = join(root, "node_modules/@openrig/acp");
mkdirSync(join(acp, "bin"), { recursive: true });
copyFileSync(join(repo, "packages/acp/package.json"), join(acp, "package.json"));
copyFileSync(join(repo, "packages/acp/bin/openrig-acp.js"), join(acp, "bin/openrig-acp.js"));
if (withCli) {
const fake = join(root, "node_modules/@openrig/cli");
mkdirSync(join(fake, "dist"), { recursive: true });
writeFileSync(join(fake, "package.json"), JSON.stringify({ name: "@openrig/cli", version: cliVersion, type: "module", bin: { rig: "dist/rig.js", "openrig-tui": "dist/tui.js" } }));
writeFileSync(join(fake, "dist/rig.js"), [
`process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), mark: process.env.FAKE_MARK }) + "\\n");`,
`process.stdin.on("data", (d) => process.stdout.write(d));`,
`process.stdin.on("end", () => process.exit(Number(process.env.FAKE_EXIT ?? 0)));`,
].join("\n"));
}
mkdirSync(join(root, "work"));
return root;
}
function run(root: string, args: string[], env: Record<string, string> = {}) {
const child = spawn(process.execPath, [join(root, "node_modules/@openrig/acp/bin/openrig-acp.js"), ...args],
{ cwd: join(root, "work"), env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"] });
let out = "", err = "";
child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
child.stderr.on("data", (d: Buffer) => { err += d.toString(); });
const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => child.on("exit", (code, signal) => resolve({ code, signal })));
return { child, done, out: () => out, err: () => err };
}
it("passes arguments, stdin, stdout, environment, working directory and the exit code through", async () => {
const root = install();
const app = run(root, ["--to", "lead@team"], { FAKE_MARK: "from-the-app", FAKE_EXIT: "3" });
app.child.stdin.end('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n');
expect(await app.done).toEqual({ code: 3, signal: null });
const [first, echoed] = app.out().trim().split("\n");
expect(JSON.parse(first!)).toEqual({ argv: ["acp", "serve", "--to", "lead@team"], cwd: realpathSync(join(root, "work")), mark: "from-the-app" });
expect(echoed).toBe('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
expect(app.err()).toBe("");
});
it("a signal from the app reaches the server, and the launcher ends the way the server did", async () => {
const app = run(install(), []);
await new Promise<void>((resolve) => app.child.stdout.once("data", () => resolve()));
app.child.kill("SIGTERM");
expect(await app.done).toEqual({ code: null, signal: "SIGTERM" });
});
it("without its @openrig/cli it says so on stderr, writes nothing to stdout, and exits 1", async () => {
const app = run(install(false), []);
app.child.stdin.end();
expect(await app.done).toEqual({ code: 1, signal: null });
expect(app.out()).toBe("");
expect(app.err()).toMatch(/^openrig-acp: @openrig\/cli isn't installed beside this package/);
});
it("an @openrig/cli of another version is named on stderr and run anyway, with the arguments and exit code passed", async () => {
const root = install(true, "0.0.0");
const app = run(root, ["--to", "lead@team"], { FAKE_EXIT: "3" });
app.child.stdin.end();
expect(await app.done).toEqual({ code: 3, signal: null });
expect(JSON.parse(app.out().trim().split("\n")[0]!).argv).toEqual(["acp", "serve", "--to", "lead@team"]);
expect(app.err()).toMatch(/^openrig-acp: found @openrig\/cli 0\.0\.0 at /);
expect(app.err()).toContain(`not the ${cli.version} this package pins; running it anyway. Reinstall @openrig/acp to get ${cli.version}.`);
});
});