mirror of
https://github.com/EveryInc/compound-engineering-plugin.git
synced 2026-10-22 13:15:13 +02:00
203 lines
8.6 KiB
TypeScript
203 lines
8.6 KiB
TypeScript
// Runs the real acpx (fetched through npx) against the stub ACP agent, so it is
|
|
// opt-in: `bun run test:acpx-contract` sets ACPX_CONTRACT=1. ACPX_VERSION overrides the pin.
|
|
import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test"
|
|
import { type ChildProcess, spawn, spawnSync } from "node:child_process"
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
import { homedir, tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
import { ACPX_PIN } from "./helpers/acpx-pin"
|
|
|
|
setDefaultTimeout(60_000)
|
|
|
|
const VERSION = process.env.ACPX_VERSION || ACPX_PIN
|
|
const STUB = path.join(__dirname, "fixtures/acp-stub-agent.mjs")
|
|
|
|
const roots: string[] = []
|
|
afterAll(() => roots.forEach((dir) => rmSync(dir, { recursive: true, force: true })))
|
|
|
|
type Line = {
|
|
id?: number | null
|
|
method?: string
|
|
params?: any
|
|
result?: any
|
|
error?: { code: number; message: string; data?: { acpxCode?: string } }
|
|
}
|
|
type Run = { code: number | null; stdout: string; stderr: string; ms: number }
|
|
type JsonRun = Run & { lines: Line[] }
|
|
|
|
function workspace() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "acpx-contract-"))
|
|
roots.push(root)
|
|
const home = path.join(root, "home")
|
|
const cwd = path.join(root, "cwd")
|
|
mkdirSync(home)
|
|
mkdirSync(cwd)
|
|
const prompt = path.join(root, "prompt.md")
|
|
writeFileSync(prompt, "Review the subject and reply.\n")
|
|
return { home, cwd, prompt }
|
|
}
|
|
|
|
function acpx(
|
|
args: string[],
|
|
options: { cwd: string; home: string; env?: Record<string, string>; onStdout?: (stdout: string, child: ChildProcess) => void },
|
|
): Promise<Run> {
|
|
const started = Date.now()
|
|
const child = spawn("npx", ["-y", `acpx@${VERSION}`, ...args], {
|
|
cwd: options.cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
// A private HOME keeps ~/.acpx/config.json out; the npm cache stays shared.
|
|
HOME: options.home,
|
|
npm_config_cache: process.env.npm_config_cache || path.join(homedir(), ".npm"),
|
|
npm_config_prefer_offline: "true",
|
|
npm_config_fetch_retries: "0",
|
|
...options.env,
|
|
},
|
|
})
|
|
let stdout = ""
|
|
let stderr = ""
|
|
child.stdout!.on("data", (chunk) => {
|
|
stdout += chunk
|
|
options.onStdout?.(stdout, child)
|
|
})
|
|
child.stderr!.on("data", (chunk) => (stderr += chunk))
|
|
return new Promise((resolve) => {
|
|
child.on("close", (code) => {
|
|
resolve({ code, stdout, stderr, ms: Date.now() - started })
|
|
})
|
|
})
|
|
}
|
|
|
|
function withLines(run: Run): JsonRun {
|
|
return { ...run, lines: run.stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line) as Line) }
|
|
}
|
|
|
|
// npx runs acpx through `sh -c`, and only bash execs it, so on Ubuntu (dash) a
|
|
// signal sent to npx stops at the shell. The workers signal acpx's whole process
|
|
// group; this finds acpx itself so the contract pins acpx's own cancel behavior.
|
|
function acpxDescendant(rootPid: number): number | undefined {
|
|
const rows = spawnSync("ps", ["-A", "-o", "pid=,ppid=,command="], { encoding: "utf8" }).stdout
|
|
.split("\n")
|
|
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/))
|
|
.filter((m): m is RegExpMatchArray => m !== null)
|
|
.map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] }))
|
|
const tree = new Set([rootPid])
|
|
for (let grew = true; grew; ) {
|
|
grew = false
|
|
for (const row of rows) if (tree.has(row.ppid) && !tree.has(row.pid)) { tree.add(row.pid); grew = true }
|
|
}
|
|
return rows.find((row) => tree.has(row.pid) && row.pid !== rootPid && /\/\.bin\/acpx\b|\/acpx\/dist\/cli/.test(row.command) && !/^\S*sh\s+-c\b/.test(row.command))?.pid
|
|
}
|
|
|
|
async function exec(mode: string, flags: string[] = [], onStdout?: (stdout: string, child: ChildProcess) => void) {
|
|
const ws = workspace()
|
|
const args = ["--format", "json", "--cwd", ws.cwd, ...flags, "--agent", `node ${STUB} ${mode}`, "exec", "--file", ws.prompt]
|
|
return withLines(await acpx(args, { ...ws, onStdout }))
|
|
}
|
|
|
|
function promptRequest(run: JsonRun) {
|
|
return run.lines.find((line) => line.method === "session/prompt")
|
|
}
|
|
function promptResponse(run: JsonRun) {
|
|
const id = promptRequest(run)?.id
|
|
return run.lines.find((line) => line.method === undefined && line.id === id)
|
|
}
|
|
function messageChunks(run: JsonRun) {
|
|
return run.lines.filter((line) => line.method === "session/update" && line.params.update.sessionUpdate === "agent_message_chunk")
|
|
}
|
|
|
|
describe.skipIf(process.env.ACPX_CONTRACT !== "1" || process.platform === "win32")(`acpx@${VERSION} contract`, () => {
|
|
test("--version reports the pinned version", async () => {
|
|
const ws = workspace()
|
|
const run = await acpx(["--version"], ws)
|
|
expect(run.code).toBe(0)
|
|
expect(run.stdout.trim()).toBe(VERSION)
|
|
})
|
|
|
|
test("a reply that ends the turn exits 0 with one message chunk and an end_turn result", async () => {
|
|
const run = await exec("end_turn")
|
|
expect(run.code).toBe(0)
|
|
expect(messageChunks(run).map((line) => line.params.update.content.text)).toEqual(["stub reply"])
|
|
expect(promptResponse(run)?.result?.stopReason).toBe("end_turn")
|
|
})
|
|
|
|
test("a denied permission request followed by end_turn exits 5 with the end_turn result", async () => {
|
|
const run = await exec("permission", ["--non-interactive-permissions", "deny"])
|
|
const ask = run.lines.find((line) => line.method === "session/request_permission")
|
|
const answer = run.lines.find((line) => line.method === undefined && line.id === ask?.id)
|
|
expect(answer?.result?.outcome).toEqual({ outcome: "selected", optionId: "reject" })
|
|
expect(run.code).toBe(5)
|
|
expect(promptResponse(run)?.result?.stopReason).toBe("end_turn")
|
|
})
|
|
|
|
test("a JSON-RPC error from the agent exits 1 and answers the prompt id with that error", async () => {
|
|
const run = await exec("error")
|
|
expect(run.code).toBe(1)
|
|
const response = promptResponse(run)
|
|
expect(response?.result).toBeUndefined()
|
|
expect(response?.error?.message).toBe("stub agent failure")
|
|
})
|
|
|
|
test("a hang past --timeout 2 exits 3 with a TIMEOUT envelope well inside 12 s", async () => {
|
|
const run = await exec("hang", ["--timeout", "2"])
|
|
expect(run.code).toBe(3)
|
|
expect(run.ms).toBeLessThan(12_000)
|
|
expect(promptResponse(run)).toBeUndefined()
|
|
expect(run.lines.at(-1)?.error?.data?.acpxCode).toBe("TIMEOUT")
|
|
})
|
|
|
|
test("SIGTERM to acpx reaches a cancel-aware agent as session/cancel and the turn ends cancelled", async () => {
|
|
let signalled = false
|
|
let polling = false
|
|
const run = await exec("cancel", [], (stdout, child) => {
|
|
if (polling || !stdout.includes('"method":"session/prompt"')) return
|
|
polling = true
|
|
const timer = setInterval(() => {
|
|
const target = acpxDescendant(child.pid!)
|
|
if (target === undefined) return
|
|
clearInterval(timer)
|
|
signalled = true
|
|
process.kill(target, "SIGTERM")
|
|
}, 50)
|
|
child.once("close", () => clearInterval(timer))
|
|
})
|
|
expect(signalled).toBe(true)
|
|
expect(run.lines.some((line) => line.method === "session/cancel")).toBe(true)
|
|
expect(promptResponse(run)?.result?.stopReason).toBe("cancelled")
|
|
expect(run.code).toBe(0)
|
|
})
|
|
|
|
test("an unknown --model exits 1 before any session/prompt and names the available models", async () => {
|
|
const run = await exec("models", ["--model", "stub-model-z"])
|
|
expect(run.code).toBe(1)
|
|
expect(promptRequest(run)).toBeUndefined()
|
|
expect(run.stdout).toContain("Available models: stub-model-a, stub-model-b")
|
|
expect(run.lines.at(-1)?.error?.data?.acpxCode).toBe("RUNTIME")
|
|
})
|
|
|
|
test("--model shifts the session/prompt id, so the result is found by id, not position", async () => {
|
|
const plain = await exec("models")
|
|
const switched = await exec("models", ["--model", "stub-model-b"])
|
|
expect(switched.code).toBe(0)
|
|
expect(switched.lines.some((line) => line.method === "session/set_model")).toBe(true)
|
|
expect(promptRequest(switched)?.id).not.toBe(promptRequest(plain)?.id)
|
|
const atUnshiftedId = switched.lines.find((line) => line.method === undefined && line.id === promptRequest(plain)?.id)
|
|
expect(atUnshiftedId?.result?.stopReason).toBeUndefined()
|
|
expect(promptResponse(switched)?.result?.stopReason).toBe("end_turn")
|
|
})
|
|
|
|
test("a .acpxrc.json in --cwd replaces a built-in agent's argv", async () => {
|
|
const ws = workspace()
|
|
writeFileSync(path.join(ws.cwd, ".acpxrc.json"), JSON.stringify({ agents: { codex: { argv: ["node", STUB, "end_turn"] } } }))
|
|
const run = withLines(
|
|
await acpx(["--format", "json", "--cwd", ws.cwd, "codex", "exec", "--file", ws.prompt], {
|
|
...ws,
|
|
env: { ACP_STUB_TEXT: "replaced by .acpxrc.json" },
|
|
}),
|
|
)
|
|
expect(run.code).toBe(0)
|
|
expect(messageChunks(run).map((line) => line.params.update.content.text)).toEqual(["replaced by .acpxrc.json"])
|
|
})
|
|
})
|