acpx/test/cli.test.ts

754 lines
23 KiB
TypeScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { InvalidArgumentError } from "commander";
import { formatPromptSessionBannerLine, parseTtlSeconds } from "../src/cli.js";
import type { SessionRecord } from "../src/types.js";
const CLI_PATH = fileURLToPath(new URL("../src/cli.js", import.meta.url));
const MOCK_AGENT_PATH = fileURLToPath(new URL("./mock-agent.js", import.meta.url));
const MOCK_AGENT_COMMAND = `node ${JSON.stringify(MOCK_AGENT_PATH)}`;
type CliRunResult = {
code: number | null;
stdout: string;
stderr: string;
};
test("parseTtlSeconds parses and rounds valid numeric values", () => {
assert.equal(parseTtlSeconds("30"), 30_000);
assert.equal(parseTtlSeconds("0"), 0);
assert.equal(parseTtlSeconds("1.49"), 1_490);
});
test("parseTtlSeconds rejects non-numeric values", () => {
assert.throws(() => parseTtlSeconds("abc"), InvalidArgumentError);
});
test("parseTtlSeconds rejects negative values", () => {
assert.throws(() => parseTtlSeconds("-1"), InvalidArgumentError);
});
test("formatPromptSessionBannerLine prints single-line prompt banner for matching cwd", () => {
const record: SessionRecord = {
id: "abc123",
sessionId: "abc123",
agentCommand: "agent-a",
cwd: "/home/user/project",
name: "calm-forest",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
};
const line = formatPromptSessionBannerLine(record, "/home/user/project");
assert.equal(
line,
"[acpx] session calm-forest (abc123) · /home/user/project · agent needs reconnect",
);
});
test("formatPromptSessionBannerLine includes routed-from path when cwd differs", () => {
const record: SessionRecord = {
id: "abc123",
sessionId: "abc123",
agentCommand: "agent-a",
cwd: "/home/user/project",
name: "calm-forest",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
};
const line = formatPromptSessionBannerLine(record, "/home/user/project/src/auth");
assert.equal(
line,
"[acpx] session calm-forest (abc123) · /home/user/project (routed from ./src/auth) · agent needs reconnect",
);
});
test("CLI resolves unknown subcommand names as raw agent commands", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const session: SessionRecord = {
id: "custom-session",
sessionId: "custom-session",
agentCommand: "custom-agent",
cwd,
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
};
await writeSessionRecord(homeDir, session);
const result = await runCli(
["--cwd", cwd, "--format", "quiet", "custom-agent", "sessions"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /custom-session/);
});
});
test("sessions new command is present in help output", async () => {
await withTempHome(async (homeDir) => {
const result = await runCli(["sessions", "--help"], homeDir);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /\bnew\b/);
assert.match(result.stdout, /\bensure\b/);
const newHelp = await runCli(["sessions", "new", "--help"], homeDir);
assert.equal(newHelp.code, 0, newHelp.stderr);
assert.match(newHelp.stdout, /--name <name>/);
const ensureHelp = await runCli(["sessions", "ensure", "--help"], homeDir);
assert.equal(ensureHelp.code, 0, ensureHelp.stderr);
assert.match(ensureHelp.stdout, /--name <name>/);
});
});
test("sessions ensure creates when missing and returns existing on subsequent calls", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
await fs.writeFile(
path.join(homeDir, ".acpx", "config.json"),
`${JSON.stringify(
{
agents: {
codex: {
command: MOCK_AGENT_COMMAND,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const first = await runCli(
["--cwd", cwd, "--format", "json", "codex", "sessions", "ensure"],
homeDir,
);
assert.equal(first.code, 0, first.stderr);
const firstPayload = JSON.parse(first.stdout.trim()) as {
type: string;
id: string;
created: boolean;
};
assert.equal(firstPayload.type, "session_ensured");
assert.equal(firstPayload.created, true);
const second = await runCli(
["--cwd", cwd, "--format", "json", "codex", "sessions", "ensure"],
homeDir,
);
assert.equal(second.code, 0, second.stderr);
const secondPayload = JSON.parse(second.stdout.trim()) as {
type: string;
id: string;
created: boolean;
};
assert.equal(secondPayload.type, "session_ensured");
assert.equal(secondPayload.created, false);
assert.equal(secondPayload.id, firstPayload.id);
});
});
test("sessions ensure resolves existing session by directory walk", async () => {
await withTempHome(async (homeDir) => {
const root = path.join(homeDir, "workspace");
const child = path.join(root, "packages", "app");
await fs.mkdir(child, { recursive: true });
await fs.mkdir(path.join(root, ".git"), { recursive: true });
await writeSessionRecord(homeDir, {
id: "parent-session",
sessionId: "parent-session",
agentCommand: "npx @zed-industries/codex-acp",
cwd: root,
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(
["--cwd", child, "--format", "json", "codex", "sessions", "ensure"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
const payload = JSON.parse(result.stdout.trim()) as {
id: string;
created: boolean;
};
assert.equal(payload.id, "parent-session");
assert.equal(payload.created, false);
});
});
test("--ttl flag is parsed for sessions commands", async () => {
await withTempHome(async (homeDir) => {
const ok = await runCli(["--ttl", "30", "--format", "json", "sessions"], homeDir);
assert.equal(ok.code, 0, ok.stderr);
assert.doesNotThrow(() => JSON.parse(ok.stdout.trim()));
const invalid = await runCli(["--ttl", "bad", "sessions"], homeDir);
assert.equal(invalid.code, 2);
assert.match(invalid.stderr, /TTL must be a non-negative number of seconds/);
const negative = await runCli(["--ttl", "-1", "sessions"], homeDir);
assert.equal(negative.code, 2);
assert.match(negative.stderr, /TTL must be a non-negative number of seconds/);
});
});
test("--auth-policy flag validates supported values", async () => {
await withTempHome(async (homeDir) => {
const ok = await runCli(
["--auth-policy", "skip", "--format", "json", "sessions"],
homeDir,
);
assert.equal(ok.code, 0, ok.stderr);
const invalid = await runCli(["--auth-policy", "bad", "sessions"], homeDir);
assert.equal(invalid.code, 2);
assert.match(invalid.stderr, /Invalid auth policy/);
});
});
test("--non-interactive-permissions validates supported values", async () => {
await withTempHome(async (homeDir) => {
const ok = await runCli(
["--non-interactive-permissions", "deny", "--format", "json", "sessions"],
homeDir,
);
assert.equal(ok.code, 0, ok.stderr);
const invalid = await runCli(
["--format", "json", "--non-interactive-permissions", "bad", "sessions"],
homeDir,
);
assert.equal(invalid.code, 2);
const payload = JSON.parse(invalid.stdout.trim()) as {
type: string;
code: string;
message: string;
};
assert.equal(payload.type, "error");
assert.equal(payload.code, "USAGE");
assert.match(payload.message, /Invalid non-interactive permission policy/);
});
});
test("prompt exits with NO_SESSION when no session exists (no auto-create)", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace", "packages", "app");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(["--cwd", cwd, "codex", "hello"], homeDir);
assert.equal(result.code, 4);
const escapedCwd = escapeRegex(cwd);
assert.match(
result.stderr,
new RegExp(
`⚠ No acpx session found \\(searched up to ${escapedCwd}\\)\\.\\nCreate one: acpx codex sessions new\\n?`,
),
);
});
});
test("json format emits structured no-session error event", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(
["--cwd", cwd, "--format", "json", "codex", "hello"],
homeDir,
);
assert.equal(result.code, 4);
const payload = JSON.parse(result.stdout.trim()) as {
type: string;
code: string;
message: string;
stream: string;
};
assert.equal(payload.type, "error");
assert.equal(payload.code, "NO_SESSION");
assert.equal(payload.stream, "control");
assert.match(payload.message, /No acpx session found/);
});
});
test("set-mode exits with NO_SESSION when no session exists", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace", "packages", "app");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(["--cwd", cwd, "codex", "set-mode", "plan"], homeDir);
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
});
});
test("set command exits with NO_SESSION when no session exists", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace", "packages", "app");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(
["--cwd", cwd, "codex", "set", "temperature", "high"],
homeDir,
);
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
});
});
test("cancel prints nothing to cancel and exits success when no session exists", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace", "packages", "app");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(["--cwd", cwd, "codex", "cancel"], homeDir);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /nothing to cancel/);
});
});
test("cancel resolves named session when -s is before subcommand", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await writeSessionRecord(homeDir, {
id: "named-cancel-session",
sessionId: "named-cancel-session",
agentCommand: "npx @zed-industries/codex-acp",
cwd,
name: "named",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(
["--cwd", cwd, "--format", "json", "codex", "-s", "named", "cancel"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
const payload = JSON.parse(result.stdout.trim()) as {
sessionId: string;
cancelled: boolean;
};
assert.equal(payload.sessionId, "named-cancel-session");
assert.equal(payload.cancelled, false);
});
});
test("status resolves named session when -s is before subcommand", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await writeSessionRecord(homeDir, {
id: "named-status-session",
sessionId: "named-status-session",
agentCommand: "npx @zed-industries/codex-acp",
cwd,
name: "named",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(
["--cwd", cwd, "--format", "json", "codex", "-s", "named", "status"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
const payload = JSON.parse(result.stdout.trim()) as {
sessionId: string | null;
status: string;
};
assert.equal(payload.sessionId, "named-status-session");
assert.equal(payload.status, "dead");
assert.notEqual(payload.status, "no-session");
});
});
test("set-mode resolves named session when -s is before subcommand", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
const missingAgentCommand = "acpx-test-missing-agent-binary";
await fs.writeFile(
path.join(homeDir, ".acpx", "config.json"),
`${JSON.stringify(
{
agents: {
codex: { command: missingAgentCommand },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeSessionRecord(homeDir, {
id: "named-set-mode-session",
sessionId: "named-set-mode-session",
agentCommand: missingAgentCommand,
cwd,
name: "named",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(
["--cwd", cwd, "codex", "-s", "named", "set-mode", "plan"],
homeDir,
);
assert.equal(result.code, 1);
assert.doesNotMatch(result.stderr, /No acpx session found/);
assert.match(result.stderr, /ENOENT|spawn|not found/i);
});
});
test("set resolves named session when -s is before subcommand", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
const missingAgentCommand = "acpx-test-missing-agent-binary-2";
await fs.writeFile(
path.join(homeDir, ".acpx", "config.json"),
`${JSON.stringify(
{
agents: {
codex: { command: missingAgentCommand },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeSessionRecord(homeDir, {
id: "named-set-config-session",
sessionId: "named-set-config-session",
agentCommand: missingAgentCommand,
cwd,
name: "named",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(
["--cwd", cwd, "codex", "-s", "named", "set", "approval_policy", "strict"],
homeDir,
);
assert.equal(result.code, 1);
assert.doesNotMatch(result.stderr, /No acpx session found/);
assert.match(result.stderr, /ENOENT|spawn|not found/i);
});
});
test("prompt reads from stdin when no prompt argument is provided", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(["--cwd", cwd, "codex"], homeDir, {
stdin: "fix the tests\n",
});
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
assert.doesNotMatch(result.stderr, /Prompt is required/);
});
});
test("prompt reads from --file for persistent prompts", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.writeFile(path.join(cwd, "prompt.md"), "fix the tests\n", "utf8");
const result = await runCli(
["--cwd", cwd, "codex", "--file", "prompt.md"],
homeDir,
);
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
assert.doesNotMatch(result.stderr, /Prompt is required/);
});
});
test("prompt supports --file - with additional argument text", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const result = await runCli(
["--cwd", cwd, "codex", "--file", "-", "additional context"],
homeDir,
{ stdin: "from stdin\n" },
);
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
assert.doesNotMatch(result.stderr, /Prompt is required/);
});
});
test("prompt subcommand accepts --file without being consumed by parent command", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.writeFile(path.join(cwd, "prompt.md"), "fix the tests\n", "utf8");
const result = await runCli(
["--cwd", cwd, "codex", "prompt", "--file", "prompt.md"],
homeDir,
);
assert.equal(result.code, 4);
assert.match(result.stderr, /No acpx session found/);
assert.doesNotMatch(result.stderr, /unknown option/i);
});
});
test("exec subcommand accepts --file without being consumed by parent command", async () => {
await withTempHome(async (homeDir) => {
const promptPath = path.join(homeDir, "prompt.txt");
await fs.writeFile(promptPath, "say exactly: file-flag-test\n", "utf8");
const result = await runCli(
["custom-agent", "exec", "--file", promptPath],
homeDir,
);
assert.equal(result.code, 1);
assert.doesNotMatch(result.stderr, /unknown option/i);
});
});
test("sessions history prints stored history entries", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await writeSessionRecord(homeDir, {
id: "history-session",
sessionId: "history-session",
agentCommand: "npx @zed-industries/codex-acp",
cwd,
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:10:00.000Z",
closed: false,
turnHistory: [
{
role: "user",
timestamp: "2026-01-01T00:01:00.000Z",
textPreview: "first message",
},
{
role: "assistant",
timestamp: "2026-01-01T00:02:00.000Z",
textPreview: "second message",
},
],
});
const result = await runCli(
["--cwd", cwd, "codex", "sessions", "history", "--limit", "1"],
homeDir,
);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /second message/);
assert.doesNotMatch(result.stdout, /first message/);
});
});
test("status reports running process when session pid is alive", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], {
stdio: "ignore",
});
try {
await writeSessionRecord(homeDir, {
id: "status-live",
sessionId: "status-live",
agentCommand: "npx @zed-industries/codex-acp",
cwd,
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
lastPromptAt: "2026-01-01T00:00:00.000Z",
closed: false,
pid: child.pid,
agentStartedAt: "2026-01-01T00:00:00.000Z",
});
const result = await runCli(["--cwd", cwd, "codex", "status"], homeDir);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /status: running/);
} finally {
if (child.pid && child.exitCode == null && child.signalCode == null) {
child.kill("SIGKILL");
}
}
});
});
test("config defaults are loaded from global and project config files", async () => {
await withTempHome(async (homeDir) => {
const cwd = path.join(homeDir, "workspace");
await fs.mkdir(cwd, { recursive: true });
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
await fs.writeFile(
path.join(homeDir, ".acpx", "config.json"),
`${JSON.stringify(
{
defaultAgent: "codex",
format: "json",
agents: {
"my-custom": { command: "custom-global" },
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(cwd, ".acpxrc.json"),
`${JSON.stringify(
{
agents: {
"my-custom": { command: "custom-project" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeSessionRecord(homeDir, {
id: "custom-config-session",
sessionId: "custom-config-session",
agentCommand: "custom-project",
cwd,
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
closed: false,
});
const result = await runCli(["--cwd", cwd, "my-custom", "sessions"], homeDir);
assert.equal(result.code, 0, result.stderr);
assert.doesNotThrow(() => JSON.parse(result.stdout.trim()));
assert.match(result.stdout, /custom-config-session/);
});
});
async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<void> {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-cli-test-home-"));
try {
await run(tempHome);
} finally {
await fs.rm(tempHome, { recursive: true, force: true });
}
}
type CliRunOptions = {
stdin?: string;
cwd?: string;
};
async function runCli(
args: string[],
homeDir: string,
options: CliRunOptions = {},
): Promise<CliRunResult> {
return await new Promise<CliRunResult>((resolve) => {
const child = spawn(process.execPath, [CLI_PATH, ...args], {
env: {
...process.env,
HOME: homeDir,
},
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
if (options.stdin != null) {
child.stdin.end(options.stdin);
} else {
child.stdin.end();
}
child.once("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
async function writeSessionRecord(
homeDir: string,
record: SessionRecord,
): Promise<void> {
const sessionDir = path.join(homeDir, ".acpx", "sessions");
await fs.mkdir(sessionDir, { recursive: true });
const file = path.join(sessionDir, `${encodeURIComponent(record.id)}.json`);
await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}