2433 lines
75 KiB
TypeScript
2433 lines
75 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import net from "node:net";
|
|
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 { AGENT_REGISTRY } from "../src/agent-registry.js";
|
|
import {
|
|
formatPromptSessionBannerLine,
|
|
parseAllowedTools,
|
|
parseMaxTurns,
|
|
parseTtlSeconds,
|
|
} from "../src/cli.js";
|
|
import { serializeSessionRecordForDisk } from "../src/session/persistence.js";
|
|
import type { SessionRecord } from "../src/types.js";
|
|
import {
|
|
cleanupOwnerArtifacts,
|
|
closeServer,
|
|
listenServer,
|
|
queuePaths,
|
|
startKeeperProcess,
|
|
stopProcess,
|
|
writeQueueOwnerLock,
|
|
} from "./queue-test-helpers.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));
|
|
function readPackageVersionForTest(): string {
|
|
const candidates = [
|
|
fileURLToPath(new URL("../package.json", import.meta.url)),
|
|
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
|
path.join(process.cwd(), "package.json"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
|
|
version?: unknown;
|
|
};
|
|
if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
|
|
return parsed.version;
|
|
}
|
|
} catch {
|
|
// continue searching
|
|
}
|
|
}
|
|
throw new Error("package.json version is missing");
|
|
}
|
|
|
|
const PACKAGE_VERSION = readPackageVersionForTest();
|
|
const MOCK_AGENT_COMMAND = `node ${JSON.stringify(MOCK_AGENT_PATH)}`;
|
|
const MOCK_AGENT_IGNORING_SIGTERM = `${MOCK_AGENT_COMMAND} --ignore-sigterm`;
|
|
const MOCK_CODEX_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --codex-session-id codex-runtime-session`;
|
|
const MOCK_CLAUDE_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --claude-session-id claude-runtime-session`;
|
|
const MOCK_AGENT_WITH_LOAD_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --supports-load-session --load-runtime-session-id loaded-runtime-session`;
|
|
const MOCK_AGENT_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS =
|
|
`${MOCK_AGENT_COMMAND} --runtime-session-id fresh-runtime-session ` +
|
|
"--supports-load-session --load-runtime-session-id resumed-runtime-session";
|
|
const MOCK_AGENT_WITH_LOAD_FALLBACK = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-fails-on-empty`;
|
|
const MOCK_AGENT_WITH_LOAD_SESSION_NOT_FOUND = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-not-found`;
|
|
const MOCK_AGENT_WITH_LOAD_FALLBACK_AND_MODE_FAILURE = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-fails-on-empty --set-session-mode-fails`;
|
|
const MOCK_AGENT_WITH_SET_MODE_INVALID_PARAMS = `${MOCK_AGENT_COMMAND} --set-session-mode-invalid-params`;
|
|
const MOCK_AGENT_WITH_SET_CONFIG_INVALID_PARAMS = `${MOCK_AGENT_COMMAND} --set-session-config-invalid-params`;
|
|
|
|
type CliRunResult = {
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
type ParsedAcpError = {
|
|
code?: number;
|
|
message?: string;
|
|
data?: Record<string, unknown> & {
|
|
acpxCode?: string;
|
|
detailCode?: string;
|
|
origin?: string;
|
|
sessionId?: string;
|
|
};
|
|
};
|
|
|
|
test("CLI --version prints package version", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const result = await runCli(["--version"], homeDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.equal(result.stderr.trim(), "");
|
|
assert.equal(result.stdout.trim(), PACKAGE_VERSION);
|
|
});
|
|
});
|
|
|
|
function parseSingleAcpErrorLine(stdout: string): ParsedAcpError {
|
|
const payload = JSON.parse(stdout.trim()) as {
|
|
jsonrpc?: string;
|
|
error?: ParsedAcpError;
|
|
};
|
|
assert.equal(payload.jsonrpc, "2.0");
|
|
assert.equal(typeof payload.error, "object");
|
|
return payload.error ?? {};
|
|
}
|
|
|
|
function parseJsonRpcLines(stdout: string): Array<Record<string, unknown>> {
|
|
const lines = stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
assert(lines.length > 0, "expected at least one stdout line");
|
|
return lines.map((line) => {
|
|
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
assert.equal(parsed.jsonrpc, "2.0");
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
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("parseAllowedTools parses empty and comma-separated values", () => {
|
|
assert.deepEqual(parseAllowedTools(""), []);
|
|
assert.deepEqual(parseAllowedTools("Read,Grep, Glob"), ["Read", "Grep", "Glob"]);
|
|
});
|
|
|
|
test("parseAllowedTools rejects empty entries", () => {
|
|
assert.throws(() => parseAllowedTools("Read,,Grep"), InvalidArgumentError);
|
|
});
|
|
|
|
test("parseMaxTurns accepts positive integers and rejects invalid values", () => {
|
|
assert.equal(parseMaxTurns("3"), 3);
|
|
assert.throws(() => parseMaxTurns("0"), InvalidArgumentError);
|
|
assert.throws(() => parseMaxTurns("1.5"), InvalidArgumentError);
|
|
});
|
|
|
|
test("formatPromptSessionBannerLine prints single-line prompt banner for matching cwd", () => {
|
|
const record = makeSessionRecord({
|
|
acpxRecordId: "abc123",
|
|
acpSessionId: "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 = makeSessionRecord({
|
|
acpxRecordId: "abc123",
|
|
acpSessionId: "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 = makeSessionRecord({
|
|
acpxRecordId: "custom-session",
|
|
acpSessionId: "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("global passthrough flags are present in help output", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const result = await runCli(["--help"], homeDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.match(result.stdout, /--model <id>/);
|
|
assert.match(result.stdout, /--allowed-tools <list>/);
|
|
assert.match(result.stdout, /--max-turns <count>/);
|
|
assert.match(result.stdout, /text, json, quiet/);
|
|
assert.match(result.stdout, /--suppress-reads/);
|
|
assert.match(result.stdout, /--no-terminal/);
|
|
});
|
|
});
|
|
|
|
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/);
|
|
assert.match(result.stdout, /\bread\b/);
|
|
assert.match(result.stdout, /\bprune\b/);
|
|
|
|
const newHelp = await runCli(["sessions", "new", "--help"], homeDir);
|
|
assert.equal(newHelp.code, 0, newHelp.stderr);
|
|
assert.match(newHelp.stdout, /--name <name>/);
|
|
assert.match(newHelp.stdout, /--resume-session <id>/);
|
|
|
|
const ensureHelp = await runCli(["sessions", "ensure", "--help"], homeDir);
|
|
assert.equal(ensureHelp.code, 0, ensureHelp.stderr);
|
|
assert.match(ensureHelp.stdout, /--name <name>/);
|
|
|
|
const readHelp = await runCli(["sessions", "read", "--help"], homeDir);
|
|
assert.equal(readHelp.code, 0, readHelp.stderr);
|
|
assert.match(readHelp.stdout, /--tail <count>/);
|
|
assert.match(ensureHelp.stdout, /--resume-session <id>/);
|
|
|
|
const pruneHelp = await runCli(["sessions", "prune", "--help"], homeDir);
|
|
assert.equal(pruneHelp.code, 0, pruneHelp.stderr);
|
|
assert.match(pruneHelp.stdout, /--dry-run/);
|
|
assert.match(pruneHelp.stdout, /--include-history/);
|
|
});
|
|
});
|
|
|
|
test("flow run command is present in help output", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const flowHelp = await runCli(["flow", "--help"], homeDir);
|
|
assert.equal(flowHelp.code, 0, flowHelp.stderr);
|
|
assert.match(flowHelp.stdout, /\brun\b/);
|
|
|
|
const runHelp = await runCli(["flow", "run", "--help"], homeDir);
|
|
assert.equal(runHelp.code, 0, runHelp.stderr);
|
|
assert.match(runHelp.stdout, /--input-json <json>/);
|
|
assert.match(runHelp.stdout, /--input-file <path>/);
|
|
assert.match(runHelp.stdout, /--default-agent <name>/);
|
|
});
|
|
});
|
|
|
|
test("sessions new --resume-session loads ACP session and stores resumed ids", 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_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const resumeSessionId = "cs_resume123";
|
|
const result = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"json",
|
|
"codex",
|
|
"sessions",
|
|
"new",
|
|
"--resume-session",
|
|
resumeSessionId,
|
|
],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
action?: unknown;
|
|
created?: unknown;
|
|
acpxRecordId?: unknown;
|
|
acpxSessionId?: unknown;
|
|
agentSessionId?: unknown;
|
|
};
|
|
assert.equal(payload.action, "session_ensured");
|
|
assert.equal(payload.created, true);
|
|
assert.equal(payload.acpxRecordId, resumeSessionId);
|
|
assert.equal(payload.acpxSessionId, resumeSessionId);
|
|
assert.equal(payload.agentSessionId, "resumed-runtime-session");
|
|
|
|
const storedRecordPath = path.join(
|
|
homeDir,
|
|
".acpx",
|
|
"sessions",
|
|
`${encodeURIComponent(resumeSessionId)}.json`,
|
|
);
|
|
const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as {
|
|
acp_session_id?: unknown;
|
|
agent_session_id?: unknown;
|
|
};
|
|
assert.equal(storedRecord.acp_session_id, resumeSessionId);
|
|
assert.equal(storedRecord.agent_session_id, "resumed-runtime-session");
|
|
});
|
|
});
|
|
|
|
test("sessions new --resume-session fails when agent does not support session/load", 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 result = await runCli(
|
|
["--cwd", cwd, "codex", "sessions", "new", "--resume-session", "cs_unsupported"],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(result.code, 1, result.stderr);
|
|
assert.match(result.stderr, /does not support session\/load/i);
|
|
});
|
|
});
|
|
|
|
test("sessions new --resume-session surfaces not-found loadSession errors without fallback", 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_WITH_LOAD_SESSION_NOT_FOUND,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const resumeSessionId = "cs_missing";
|
|
const result = await runCli(
|
|
["--cwd", cwd, "codex", "sessions", "new", "--resume-session", resumeSessionId],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(result.code, 4, result.stderr);
|
|
assert.match(result.stderr, /Failed to resume ACP session cs_missing: Resource not found/);
|
|
|
|
const sessionsDir = path.join(homeDir, ".acpx", "sessions");
|
|
const entries = await fs.readdir(sessionsDir).catch(() => [] as string[]);
|
|
assert.equal(entries.includes(`${encodeURIComponent(resumeSessionId)}.json`), false);
|
|
});
|
|
});
|
|
|
|
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 Record<string, unknown>;
|
|
assert.equal(firstPayload.action, "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 Record<string, unknown>;
|
|
assert.equal(secondPayload.action, "session_ensured");
|
|
assert.equal(secondPayload.created, false);
|
|
assert.equal(secondPayload.acpxRecordId, firstPayload.acpxRecordId);
|
|
});
|
|
});
|
|
|
|
test("sessions ensure --resume-session loads ACP session when creating missing session", 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_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const resumeSessionId = "cs_ensure_resume";
|
|
const result = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"json",
|
|
"codex",
|
|
"sessions",
|
|
"ensure",
|
|
"--resume-session",
|
|
resumeSessionId,
|
|
],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
created?: unknown;
|
|
acpxRecordId?: unknown;
|
|
acpxSessionId?: unknown;
|
|
agentSessionId?: unknown;
|
|
};
|
|
assert.equal(payload.created, true);
|
|
assert.equal(payload.acpxRecordId, resumeSessionId);
|
|
assert.equal(payload.acpxSessionId, resumeSessionId);
|
|
assert.equal(payload.agentSessionId, "resumed-runtime-session");
|
|
});
|
|
});
|
|
|
|
test("sessions ensure exits even when agent ignores SIGTERM", 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_IGNORING_SIGTERM,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "ensure"],
|
|
homeDir,
|
|
{ timeoutMs: 8_000 },
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
action?: unknown;
|
|
created?: unknown;
|
|
acpxRecordId?: unknown;
|
|
};
|
|
assert.equal(payload.action, "session_ensured");
|
|
assert.equal(payload.created, true);
|
|
assert.equal(typeof payload.acpxRecordId, "string");
|
|
|
|
const storedRecord = JSON.parse(
|
|
await fs.readFile(
|
|
path.join(
|
|
homeDir,
|
|
".acpx",
|
|
"sessions",
|
|
`${encodeURIComponent(payload.acpxRecordId as string)}.json`,
|
|
),
|
|
"utf8",
|
|
),
|
|
) as SessionRecord;
|
|
|
|
if (storedRecord.pid != null) {
|
|
const exited = await waitForPidExit(storedRecord.pid, 2_000);
|
|
assert.equal(exited, true);
|
|
}
|
|
});
|
|
});
|
|
|
|
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, {
|
|
acpxRecordId: "parent-session",
|
|
acpSessionId: "parent-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
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 Record<string, unknown>;
|
|
assert.equal(payload.acpxRecordId, "parent-session");
|
|
assert.equal(payload.action, "session_ensured");
|
|
assert.equal(payload.created, false);
|
|
});
|
|
});
|
|
|
|
test("sessions and status surface agentSessionId for codex and claude in JSON mode", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const runtimeScenarios = [
|
|
{
|
|
agentName: "codex",
|
|
command: MOCK_CODEX_AGENT_WITH_RUNTIME_SESSION_ID,
|
|
expectedRuntimeSessionId: "codex-runtime-session",
|
|
},
|
|
{
|
|
agentName: "claude",
|
|
command: MOCK_CLAUDE_AGENT_WITH_RUNTIME_SESSION_ID,
|
|
expectedRuntimeSessionId: "claude-runtime-session",
|
|
},
|
|
] as const;
|
|
|
|
const agentsConfig = Object.fromEntries(
|
|
runtimeScenarios.map((scenario) => [scenario.agentName, { command: scenario.command }]),
|
|
);
|
|
|
|
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(homeDir, ".acpx", "config.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
agents: agentsConfig,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
for (const scenario of runtimeScenarios) {
|
|
const created = await runCli(
|
|
["--cwd", cwd, "--format", "json", scenario.agentName, "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(created.code, 0, created.stderr);
|
|
const createdPayload = JSON.parse(created.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(createdPayload.action, "session_ensured");
|
|
assert.equal(createdPayload.created, true);
|
|
assert.equal(createdPayload.agentSessionId, scenario.expectedRuntimeSessionId);
|
|
|
|
const ensured = await runCli(
|
|
["--cwd", cwd, "--format", "json", scenario.agentName, "sessions", "ensure"],
|
|
homeDir,
|
|
);
|
|
assert.equal(ensured.code, 0, ensured.stderr);
|
|
const ensuredPayload = JSON.parse(ensured.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(ensuredPayload.action, "session_ensured");
|
|
assert.equal(ensuredPayload.created, false);
|
|
assert.equal(ensuredPayload.agentSessionId, scenario.expectedRuntimeSessionId);
|
|
|
|
const status = await runCli(
|
|
["--cwd", cwd, "--format", "json", scenario.agentName, "status"],
|
|
homeDir,
|
|
);
|
|
assert.equal(status.code, 0, status.stderr);
|
|
const statusPayload = JSON.parse(status.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(statusPayload.action, "status_snapshot");
|
|
assert.equal(statusPayload.agentSessionId, scenario.expectedRuntimeSessionId);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("prompt reconciles agentSessionId from loadSession metadata", 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_WITH_LOAD_RUNTIME_SESSION_ID,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const sessionId = "resume-runtime-session";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_WITH_LOAD_RUNTIME_SESSION_ID,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
|
closed: false,
|
|
});
|
|
|
|
const prompt = await runCli(
|
|
["--cwd", cwd, "--ttl", "0.01", "codex", "prompt", "echo hello"],
|
|
homeDir,
|
|
);
|
|
assert.equal(prompt.code, 0, prompt.stderr);
|
|
|
|
const storedRecordPath = path.join(
|
|
homeDir,
|
|
".acpx",
|
|
"sessions",
|
|
`${encodeURIComponent(sessionId)}.json`,
|
|
);
|
|
const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
assert.equal(storedRecord.agent_session_id, "loaded-runtime-session");
|
|
});
|
|
});
|
|
|
|
test("set-mode persists across load fallback and replays on fresh ACP sessions", 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_WITH_LOAD_FALLBACK,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const sessionId = "mode-replay-session";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_WITH_LOAD_FALLBACK,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
|
closed: false,
|
|
});
|
|
|
|
const setPlan = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "set-mode", "plan"],
|
|
homeDir,
|
|
);
|
|
assert.equal(setPlan.code, 0, setPlan.stderr);
|
|
const setPlanPayload = JSON.parse(setPlan.stdout.trim()) as {
|
|
acpxSessionId?: unknown;
|
|
};
|
|
|
|
const checkPlan = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "set", "reasoning_effort", "high"],
|
|
homeDir,
|
|
);
|
|
assert.equal(checkPlan.code, 0, checkPlan.stderr);
|
|
const checkPlanPayload = JSON.parse(checkPlan.stdout.trim()) as {
|
|
acpxSessionId?: unknown;
|
|
configOptions?: Array<{ id?: string; currentValue?: string }>;
|
|
};
|
|
const modeAfterPlan =
|
|
checkPlanPayload.configOptions?.find((option) => option.id === "mode")?.currentValue ?? "";
|
|
assert.equal(modeAfterPlan, "plan");
|
|
assert.notEqual(checkPlanPayload.acpxSessionId, setPlanPayload.acpxSessionId);
|
|
|
|
const setAuto = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "set-mode", "auto"],
|
|
homeDir,
|
|
);
|
|
assert.equal(setAuto.code, 0, setAuto.stderr);
|
|
const setAutoPayload = JSON.parse(setAuto.stdout.trim()) as {
|
|
acpxSessionId?: unknown;
|
|
};
|
|
|
|
const checkAuto = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "set", "reasoning_effort", "medium"],
|
|
homeDir,
|
|
);
|
|
assert.equal(checkAuto.code, 0, checkAuto.stderr);
|
|
const checkAutoPayload = JSON.parse(checkAuto.stdout.trim()) as {
|
|
acpxSessionId?: unknown;
|
|
configOptions?: Array<{ id?: string; currentValue?: string }>;
|
|
};
|
|
const modeAfterAuto =
|
|
checkAutoPayload.configOptions?.find((option) => option.id === "mode")?.currentValue ?? "";
|
|
assert.equal(modeAfterAuto, "auto");
|
|
assert.notEqual(checkAutoPayload.acpxSessionId, setAutoPayload.acpxSessionId);
|
|
|
|
const storedRecordPath = path.join(
|
|
homeDir,
|
|
".acpx",
|
|
"sessions",
|
|
`${encodeURIComponent(sessionId)}.json`,
|
|
);
|
|
const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as {
|
|
acpx?: {
|
|
desired_mode_id?: string;
|
|
};
|
|
};
|
|
assert.equal(storedRecord.acpx?.desired_mode_id, "auto");
|
|
});
|
|
});
|
|
|
|
test("codex thought_level aliases to reasoning_effort", 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 sessionId = "codex-thought-level-alias";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_COMMAND,
|
|
cwd,
|
|
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", "set", "thought_level", "high"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
action?: string;
|
|
configId?: string;
|
|
value?: string;
|
|
configOptions?: Array<{ id?: string; currentValue?: string; category?: string }>;
|
|
};
|
|
assert.equal(payload.action, "config_set");
|
|
assert.equal(payload.configId, "thought_level");
|
|
assert.equal(payload.value, "high");
|
|
const reasoningEffort = payload.configOptions?.find(
|
|
(option) => option.id === "reasoning_effort",
|
|
);
|
|
assert.equal(reasoningEffort?.currentValue, "high");
|
|
assert.equal(reasoningEffort?.category, "thought_level");
|
|
});
|
|
});
|
|
|
|
test("codex set model passes the requested model through unchanged", 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 sessionId = "codex-model-alias";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_COMMAND,
|
|
cwd,
|
|
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", "set", "model", "GPT-5-2"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
action?: string;
|
|
modelId?: string;
|
|
};
|
|
assert.equal(payload.action, "model_set");
|
|
assert.equal(payload.modelId, "GPT-5-2");
|
|
});
|
|
});
|
|
|
|
test("set-mode load fallback failure does not persist the fresh session id to disk", 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_WITH_LOAD_FALLBACK_AND_MODE_FAILURE,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const sessionId = "mode-replay-session";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_WITH_LOAD_FALLBACK_AND_MODE_FAILURE,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
|
closed: false,
|
|
acpx: {
|
|
desired_mode_id: "plan",
|
|
},
|
|
});
|
|
|
|
const result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "set-mode", "plan"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 1, result.stderr);
|
|
const error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(error.data?.acpxCode, "RUNTIME");
|
|
assert.equal(error.data?.detailCode, "SESSION_MODE_REPLAY_FAILED");
|
|
|
|
const storedRecordPath = path.join(
|
|
homeDir,
|
|
".acpx",
|
|
"sessions",
|
|
`${encodeURIComponent(sessionId)}.json`,
|
|
);
|
|
const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as {
|
|
acp_session_id?: string;
|
|
acpx?: {
|
|
desired_mode_id?: string;
|
|
};
|
|
};
|
|
assert.equal(storedRecord.acp_session_id, sessionId);
|
|
assert.equal(storedRecord.acpx?.desired_mode_id, "plan");
|
|
});
|
|
});
|
|
|
|
test("set-mode surfaces actionable guidance when agent rejects session/set_mode params", 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_WITH_SET_MODE_INVALID_PARAMS,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const sessionId = "set-mode-invalid-params";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_WITH_SET_MODE_INVALID_PARAMS,
|
|
cwd,
|
|
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", "set-mode", "plan"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 1, result.stderr);
|
|
const error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(typeof error.code, "number");
|
|
assert.match(error.message ?? "", /Internal error|session\/set_mode/);
|
|
});
|
|
});
|
|
|
|
test("set returns an error when agent rejects unsupported session config params", 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_WITH_SET_CONFIG_INVALID_PARAMS,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const sessionId = "set-config-invalid-params";
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: MOCK_AGENT_WITH_SET_CONFIG_INVALID_PARAMS,
|
|
cwd,
|
|
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", "set", "approval_policy", "strict"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 1, result.stderr);
|
|
const error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(typeof error.code, "number");
|
|
assert.match(error.message ?? "", /Internal error|session\/set_config_option/);
|
|
});
|
|
});
|
|
|
|
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 error = parseSingleAcpErrorLine(invalid.stdout);
|
|
assert.equal(error.code, -32602);
|
|
assert.equal(error.data?.acpxCode, "USAGE");
|
|
assert.match(error.message ?? "", /Invalid non-interactive permission policy/);
|
|
});
|
|
});
|
|
|
|
test("--json-strict requires --format json", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const result = await runCli(["--json-strict", "sessions"], homeDir);
|
|
assert.equal(result.code, 2);
|
|
assert.equal(result.stderr.trim(), "");
|
|
const error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(error.code, -32602);
|
|
assert.equal(error.data?.acpxCode, "USAGE");
|
|
assert.match(error.message ?? "", /--json-strict requires --format json/);
|
|
});
|
|
});
|
|
|
|
test("--json-strict rejects --verbose", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const result = await runCli(
|
|
["--format", "json", "--json-strict", "--verbose", "sessions"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 2);
|
|
assert.equal(result.stderr.trim(), "");
|
|
const error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(error.code, -32602);
|
|
assert.equal(error.data?.acpxCode, "USAGE");
|
|
assert.match(error.message ?? "", /--json-strict cannot be combined with --verbose/);
|
|
});
|
|
});
|
|
|
|
test("queued prompt failures emit exactly one JSON error event", 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 session = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(session.code, 0, session.stderr);
|
|
|
|
const blocker = spawn(
|
|
process.execPath,
|
|
[CLI_PATH, "--cwd", cwd, "codex", "prompt", "sleep 1500"],
|
|
{
|
|
env: { ...process.env, HOME: homeDir },
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
},
|
|
);
|
|
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 200);
|
|
});
|
|
|
|
const writeResult = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"json",
|
|
"--non-interactive-permissions",
|
|
"fail",
|
|
"codex",
|
|
"prompt",
|
|
`write ${path.join(cwd, "x.txt")} hi`,
|
|
],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(writeResult.code, 5, writeResult.stderr);
|
|
|
|
const events = writeResult.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
|
|
const errors = events.filter(
|
|
(event) => typeof event.error === "object" && event.error !== null,
|
|
);
|
|
assert.equal(errors.length, 1, writeResult.stdout);
|
|
assert.equal((errors[0]?.error as { code?: unknown } | undefined)?.code, -32603);
|
|
assert.notEqual(
|
|
(errors[0]?.error as { data?: { sessionId?: unknown } } | undefined)?.data?.sessionId,
|
|
"unknown",
|
|
);
|
|
} finally {
|
|
if (blocker.exitCode === null && blocker.signalCode == null) {
|
|
blocker.kill("SIGKILL");
|
|
await new Promise<void>((resolve) => {
|
|
blocker.once("close", () => resolve());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("json-strict queued prompt failure emits JSON-RPC lines only", 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 session = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(session.code, 0, session.stderr);
|
|
|
|
const blocker = spawn(
|
|
process.execPath,
|
|
[CLI_PATH, "--cwd", cwd, "codex", "prompt", "sleep 1500"],
|
|
{
|
|
env: { ...process.env, HOME: homeDir },
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
},
|
|
);
|
|
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 200);
|
|
});
|
|
|
|
const writeResult = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"json",
|
|
"--json-strict",
|
|
"--non-interactive-permissions",
|
|
"fail",
|
|
"codex",
|
|
"prompt",
|
|
`write ${path.join(cwd, "x.txt")} hi`,
|
|
],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(writeResult.code, 5, writeResult.stderr);
|
|
assert.equal(writeResult.stderr.trim(), "");
|
|
|
|
const events = parseJsonRpcLines(writeResult.stdout);
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
typeof event.error === "object" &&
|
|
event.error !== null &&
|
|
typeof (event.error as { code?: unknown }).code === "number",
|
|
),
|
|
true,
|
|
);
|
|
} finally {
|
|
if (blocker.exitCode === null && blocker.signalCode == null) {
|
|
blocker.kill("SIGKILL");
|
|
await new Promise<void>((resolve) => {
|
|
blocker.once("close", () => resolve());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("queued prompt failures remain visible in quiet mode", 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 session = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(session.code, 0, session.stderr);
|
|
|
|
const blocker = spawn(
|
|
process.execPath,
|
|
[CLI_PATH, "--cwd", cwd, "codex", "prompt", "sleep 1500"],
|
|
{
|
|
env: { ...process.env, HOME: homeDir },
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
},
|
|
);
|
|
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 200);
|
|
});
|
|
|
|
const writeResult = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"quiet",
|
|
"--non-interactive-permissions",
|
|
"fail",
|
|
"codex",
|
|
"prompt",
|
|
`write ${path.join(cwd, "x.txt")} hi`,
|
|
],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(writeResult.code, 5);
|
|
assert.match(writeResult.stdout, /error:\s*Internal error/i);
|
|
assert.match(writeResult.stderr, /Permission prompt unavailable in non-interactive mode/);
|
|
} finally {
|
|
if (blocker.exitCode === null && blocker.signalCode == null) {
|
|
blocker.kill("SIGKILL");
|
|
await new Promise<void>((resolve) => {
|
|
blocker.once("close", () => resolve());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("non-queued write permission denial exits with code 5", 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 session = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(session.code, 0, session.stderr);
|
|
|
|
const writeResult = await runCli(
|
|
[
|
|
"--cwd",
|
|
cwd,
|
|
"--format",
|
|
"quiet",
|
|
"--approve-reads",
|
|
"codex",
|
|
"prompt",
|
|
`write ${path.join(cwd, "x.txt")} hi`,
|
|
],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(writeResult.code, 5);
|
|
assert.match(writeResult.stdout, /error:\s*Internal error/i);
|
|
});
|
|
});
|
|
|
|
test("--json-strict suppresses session banners on stderr", 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 result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "--json-strict", "codex", "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.equal(result.stderr.trim(), "");
|
|
const payload = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(payload.action, "session_ensured");
|
|
assert.equal(payload.created, true);
|
|
assert.equal(typeof payload.acpxRecordId, "string");
|
|
});
|
|
});
|
|
|
|
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 error = parseSingleAcpErrorLine(result.stdout);
|
|
assert.equal(error.code, -32002);
|
|
assert.equal(error.data?.acpxCode, "NO_SESSION");
|
|
assert.match(error.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, {
|
|
acpxRecordId: "named-cancel-session",
|
|
acpSessionId: "named-cancel-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
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 Record<string, unknown>;
|
|
assert.equal(payload.action, "cancel_result");
|
|
assert.equal(payload.acpxRecordId, "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, {
|
|
acpxRecordId: "named-status-session",
|
|
acpSessionId: "named-status-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
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 Record<string, unknown>;
|
|
assert.equal(payload.action, "status_snapshot");
|
|
assert.equal(payload.acpxRecordId, "named-status-session");
|
|
assert.equal(payload.status, "idle");
|
|
assert.equal(payload.summary, "session idle; queue owner will start on next prompt");
|
|
assert.notEqual(payload.status, "no-session");
|
|
assert.equal(payload.agentSessionId, undefined);
|
|
});
|
|
});
|
|
|
|
test("status reports idle for resumable sessions without a live queue owner", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: "idle-status-session",
|
|
acpSessionId: "idle-status-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:01:00.000Z",
|
|
lastPromptAt: "2026-01-01T00:01:00.000Z",
|
|
closed: false,
|
|
pid: 12345,
|
|
agentStartedAt: "2026-01-01T00:00:00.000Z",
|
|
lastAgentExitCode: 0,
|
|
lastAgentExitAt: "2026-01-01T00:02:00.000Z",
|
|
});
|
|
|
|
const json = await runCli(["--cwd", cwd, "--format", "json", "codex", "status"], homeDir);
|
|
assert.equal(json.code, 0, json.stderr);
|
|
const payload = JSON.parse(json.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(payload.action, "status_snapshot");
|
|
assert.equal(payload.status, "idle");
|
|
assert.equal(payload.summary, "session idle; queue owner will start on next prompt");
|
|
assert.equal(payload.exitCode, undefined);
|
|
|
|
const text = await runCli(["--cwd", cwd, "codex", "status"], homeDir);
|
|
assert.equal(text.code, 0, text.stderr);
|
|
assert.match(text.stdout, /status: idle/);
|
|
assert.doesNotMatch(text.stdout, /exitCode:/);
|
|
});
|
|
});
|
|
|
|
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, {
|
|
acpxRecordId: "named-set-mode-session",
|
|
acpSessionId: "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, {
|
|
acpxRecordId: "named-set-config-session",
|
|
acpSessionId: "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("exec accepts structured ACP prompt blocks from stdin", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const result = await runCli(
|
|
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "exec"],
|
|
homeDir,
|
|
{
|
|
stdin: JSON.stringify([
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
|
|
]),
|
|
},
|
|
);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout.trim()) as Array<Record<string, unknown>>;
|
|
assert.deepEqual(payload, [
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "image/png", bytes: 8 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("prompt preserves structured ACP prompt blocks through the queue owner", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const created = await runCli(
|
|
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "sessions", "new"],
|
|
homeDir,
|
|
);
|
|
assert.equal(created.code, 0, created.stderr);
|
|
|
|
const result = await runCli(
|
|
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "prompt"],
|
|
homeDir,
|
|
{
|
|
stdin: JSON.stringify([
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
|
|
]),
|
|
},
|
|
);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout.trim()) as Array<Record<string, unknown>>;
|
|
assert.deepEqual(payload, [
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "image/png", bytes: 8 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("exec rejects structured image prompts with invalid mime types", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const result = await runCli(
|
|
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "exec"],
|
|
homeDir,
|
|
{
|
|
stdin: JSON.stringify([
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "application/json", data: "aW1hZ2U=" },
|
|
]),
|
|
},
|
|
);
|
|
|
|
assert.equal(result.code, 2);
|
|
assert.match(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
/image block mimeType must start with image\//i,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("exec rejects structured image prompts with invalid base64 payloads", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const result = await runCli(
|
|
["--agent", MOCK_AGENT_COMMAND, "--cwd", cwd, "--format", "quiet", "exec"],
|
|
homeDir,
|
|
{
|
|
stdin: JSON.stringify([
|
|
{ type: "text", text: "inspect-prompt" },
|
|
{ type: "image", mimeType: "image/png", data: "%%%" },
|
|
]),
|
|
},
|
|
);
|
|
|
|
assert.equal(result.code, 2);
|
|
assert.match(`${result.stdout}\n${result.stderr}`, /image block data must be valid base64/i);
|
|
});
|
|
});
|
|
|
|
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, {
|
|
acpxRecordId: "history-session",
|
|
acpSessionId: "history-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:10:00.000Z",
|
|
closed: false,
|
|
title: null,
|
|
messages: [
|
|
{
|
|
User: {
|
|
id: "7d7b0e67-9725-4f57-ba31-491bf4f97767",
|
|
content: [{ Text: "first message" }],
|
|
},
|
|
},
|
|
{
|
|
Agent: {
|
|
content: [{ Text: "second message" }],
|
|
tool_results: {},
|
|
},
|
|
},
|
|
],
|
|
updated_at: "2026-01-01T00:02:00.000Z",
|
|
cumulative_token_usage: {},
|
|
request_token_usage: {},
|
|
});
|
|
|
|
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("sessions prune dry-run previews closed sessions without deleting", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: "prune-dry-run",
|
|
acpSessionId: "prune-dry-run",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:10:00.000Z",
|
|
closed: true,
|
|
closedAt: "2026-01-01T00:10:00.000Z",
|
|
});
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: "prune-open",
|
|
acpSessionId: "prune-open",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:20:00.000Z",
|
|
closed: false,
|
|
});
|
|
|
|
const result = await runCli(["--cwd", cwd, "codex", "sessions", "prune", "--dry-run"], homeDir);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.match(result.stdout, /\[DRY RUN\] Would prune 1 session/);
|
|
assert.match(result.stdout, /prune-dry-run/);
|
|
assert.doesNotMatch(result.stdout, /prune-open/);
|
|
assert.ok(await fileExists(sessionFilePath(homeDir, "prune-dry-run")));
|
|
assert.ok(await fileExists(sessionFilePath(homeDir, "prune-open")));
|
|
});
|
|
});
|
|
|
|
test("sessions prune supports json output and include-history", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
const sessionDir = path.join(homeDir, ".acpx", "sessions");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: "prune-json",
|
|
acpSessionId: "prune-json",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:10:00.000Z",
|
|
closed: true,
|
|
closedAt: "2026-01-01T00:10:00.000Z",
|
|
});
|
|
|
|
const safeId = encodeURIComponent("prune-json");
|
|
const streamFile = path.join(sessionDir, `${safeId}.stream.ndjson`);
|
|
await fs.writeFile(streamFile, "event-data\n", "utf8");
|
|
|
|
const result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "sessions", "prune", "--include-history"],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout) as {
|
|
action?: string;
|
|
dryRun?: boolean;
|
|
count?: number;
|
|
bytesFreed?: number;
|
|
pruned?: string[];
|
|
};
|
|
assert.equal(payload.action, "sessions_pruned");
|
|
assert.equal(payload.dryRun, false);
|
|
assert.equal(payload.count, 1);
|
|
assert.ok((payload.bytesFreed ?? 0) > 0);
|
|
assert.deepEqual(payload.pruned, ["prune-json"]);
|
|
assert.ok(!(await fileExists(sessionFilePath(homeDir, "prune-json"))));
|
|
assert.ok(!(await fileExists(streamFile)));
|
|
});
|
|
});
|
|
|
|
test("sessions read prints full history by default and supports --tail", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: "read-session",
|
|
acpSessionId: "read-session",
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
cwd,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
lastUsedAt: "2026-01-01T00:10:00.000Z",
|
|
closed: false,
|
|
title: null,
|
|
messages: [
|
|
{
|
|
User: {
|
|
id: "4cb89fd7-0dd5-4bdd-8f50-3de20eaa58a5",
|
|
content: [{ Text: "first message" }],
|
|
},
|
|
},
|
|
{
|
|
Agent: {
|
|
content: [{ Text: "second message" }],
|
|
tool_results: {},
|
|
},
|
|
},
|
|
],
|
|
updated_at: "2026-01-01T00:02:00.000Z",
|
|
cumulative_token_usage: {},
|
|
request_token_usage: {},
|
|
});
|
|
|
|
const full = await runCli(["--cwd", cwd, "codex", "sessions", "read"], homeDir);
|
|
assert.equal(full.code, 0, full.stderr);
|
|
assert.match(full.stdout, /first message/);
|
|
assert.match(full.stdout, /second message/);
|
|
assert.match(full.stdout, /\(2\/2 shown\)/);
|
|
|
|
const tail = await runCli(["--cwd", cwd, "codex", "sessions", "read", "--tail", "1"], homeDir);
|
|
assert.equal(tail.code, 0, tail.stderr);
|
|
assert.match(tail.stdout, /second message/);
|
|
assert.doesNotMatch(tail.stdout, /first message/);
|
|
assert.match(tail.stdout, /\(1\/2 shown\)/);
|
|
});
|
|
});
|
|
|
|
test("status reports running queue owner when owner socket is reachable", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const sessionId = "status-live";
|
|
const keeper = await startKeeperProcess();
|
|
const { lockPath, socketPath } = queuePaths(homeDir, sessionId);
|
|
|
|
const server = net.createServer((socket) => {
|
|
socket.end();
|
|
});
|
|
|
|
try {
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
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: keeper.pid,
|
|
agentStartedAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
|
|
await writeQueueOwnerLock({
|
|
lockPath,
|
|
pid: keeper.pid,
|
|
sessionId,
|
|
socketPath,
|
|
});
|
|
await listenServer(server, socketPath);
|
|
|
|
const result = await runCli(["--cwd", cwd, "codex", "status"], homeDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.match(result.stdout, /status: running/);
|
|
} finally {
|
|
await closeServer(server);
|
|
await cleanupOwnerArtifacts({ socketPath, lockPath });
|
|
stopProcess(keeper);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("status reports dead when queue owner lease is present but unreachable", async () => {
|
|
await withTempHome(async (homeDir) => {
|
|
const cwd = path.join(homeDir, "workspace");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const sessionId = "status-unreachable-owner";
|
|
const keeper = await startKeeperProcess();
|
|
const { lockPath, socketPath } = queuePaths(homeDir, sessionId);
|
|
|
|
try {
|
|
await writeSessionRecord(homeDir, {
|
|
acpxRecordId: sessionId,
|
|
acpSessionId: sessionId,
|
|
agentCommand: AGENT_REGISTRY.codex,
|
|
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: keeper.pid,
|
|
agentStartedAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
|
|
await writeQueueOwnerLock({
|
|
lockPath,
|
|
pid: keeper.pid,
|
|
sessionId,
|
|
socketPath,
|
|
});
|
|
|
|
const result = await runCli(["--cwd", cwd, "--format", "json", "codex", "status"], homeDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
assert.equal(payload.status, "dead");
|
|
assert.equal(payload.summary, "queue owner unavailable");
|
|
} finally {
|
|
await cleanupOwnerArtifacts({ socketPath, lockPath });
|
|
stopProcess(keeper);
|
|
}
|
|
});
|
|
});
|
|
|
|
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, {
|
|
acpxRecordId: "custom-config-session",
|
|
acpSessionId: "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/);
|
|
});
|
|
});
|
|
|
|
test("exec subcommand is blocked when disableExec is true", 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(
|
|
{
|
|
disableExec: true,
|
|
agents: {
|
|
codex: { command: MOCK_AGENT_COMMAND },
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = await runCli(["--cwd", cwd, "codex", "exec", "hello"], homeDir);
|
|
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, /exec subcommand is disabled by configuration/);
|
|
});
|
|
});
|
|
|
|
test("exec subcommand is blocked in json format when disableExec is true", 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(
|
|
{
|
|
disableExec: true,
|
|
agents: {
|
|
codex: { command: MOCK_AGENT_COMMAND },
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "exec", "hello"],
|
|
homeDir,
|
|
);
|
|
|
|
assert.equal(result.code, 1);
|
|
const payload = JSON.parse(result.stdout.trim()) as {
|
|
error?: { code?: number; data?: { acpxCode?: string } };
|
|
};
|
|
assert.equal(payload.error?.code, -32603);
|
|
assert.equal(payload.error?.data?.acpxCode, "EXEC_DISABLED");
|
|
});
|
|
});
|
|
|
|
test("exec subcommand works when disableExec is false", 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(
|
|
{
|
|
disableExec: false,
|
|
agents: {
|
|
codex: { command: MOCK_AGENT_COMMAND },
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = await runCli(
|
|
["--cwd", cwd, "--format", "json", "codex", "exec", "echo hello"],
|
|
homeDir,
|
|
);
|
|
|
|
// exec should work (exit code 0) since disableExec is false
|
|
assert.equal(result.code, 0, result.stderr);
|
|
});
|
|
});
|
|
|
|
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, maxRetries: 3, retryDelay: 50 });
|
|
}
|
|
}
|
|
|
|
type CliRunOptions = {
|
|
stdin?: string;
|
|
cwd?: string;
|
|
timeoutMs?: number;
|
|
env?: NodeJS.ProcessEnv;
|
|
};
|
|
|
|
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,
|
|
...options.env,
|
|
},
|
|
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();
|
|
}
|
|
|
|
let timedOut = false;
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
if (options.timeoutMs != null && options.timeoutMs > 0) {
|
|
timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
if (child.exitCode == null && child.signalCode == null) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, options.timeoutMs);
|
|
}
|
|
|
|
child.once("close", (code) => {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
if (timedOut) {
|
|
stderr += `[test] timed out after ${options.timeoutMs}ms\n`;
|
|
}
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
return true;
|
|
}
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function makeSessionRecord(
|
|
record: Partial<SessionRecord> & {
|
|
acpxRecordId: string;
|
|
acpSessionId: string;
|
|
agentCommand: string;
|
|
cwd: string;
|
|
createdAt?: string;
|
|
lastUsedAt?: string;
|
|
},
|
|
): SessionRecord {
|
|
const timestamp = "2026-01-01T00:00:00.000Z";
|
|
return {
|
|
schema: "acpx.session.v1",
|
|
acpxRecordId: record.acpxRecordId,
|
|
acpSessionId: record.acpSessionId,
|
|
agentSessionId: record.agentSessionId,
|
|
agentCommand: record.agentCommand,
|
|
cwd: record.cwd,
|
|
name: record.name,
|
|
createdAt: record.createdAt ?? timestamp,
|
|
lastUsedAt: record.lastUsedAt ?? timestamp,
|
|
lastSeq: record.lastSeq ?? 0,
|
|
lastRequestId: record.lastRequestId,
|
|
eventLog: record.eventLog ?? {
|
|
active_path: `.stream.ndjson`,
|
|
segment_count: 1,
|
|
max_segment_bytes: 1024,
|
|
max_segments: 1,
|
|
last_write_at: record.lastUsedAt ?? timestamp,
|
|
last_write_error: null,
|
|
},
|
|
closed: record.closed ?? false,
|
|
closedAt: record.closedAt,
|
|
pid: record.pid,
|
|
agentStartedAt: record.agentStartedAt,
|
|
lastPromptAt: record.lastPromptAt,
|
|
lastAgentExitCode: record.lastAgentExitCode,
|
|
lastAgentExitSignal: record.lastAgentExitSignal,
|
|
lastAgentExitAt: record.lastAgentExitAt,
|
|
lastAgentDisconnectReason: record.lastAgentDisconnectReason,
|
|
protocolVersion: record.protocolVersion,
|
|
agentCapabilities: record.agentCapabilities,
|
|
title: record.title ?? null,
|
|
messages: record.messages ?? [],
|
|
updated_at: record.updated_at ?? record.lastUsedAt ?? timestamp,
|
|
cumulative_token_usage: record.cumulative_token_usage ?? {},
|
|
request_token_usage: record.request_token_usage ?? {},
|
|
acpx: record.acpx,
|
|
};
|
|
}
|
|
|
|
async function writeSessionRecord(
|
|
homeDir: string,
|
|
record: Partial<SessionRecord> & {
|
|
acpxRecordId: string;
|
|
acpSessionId: string;
|
|
agentCommand: string;
|
|
cwd: string;
|
|
},
|
|
): Promise<void> {
|
|
const sessionDir = path.join(homeDir, ".acpx", "sessions");
|
|
await fs.mkdir(sessionDir, { recursive: true });
|
|
const file = path.join(sessionDir, `${encodeURIComponent(record.acpxRecordId)}.json`);
|
|
await fs.writeFile(
|
|
file,
|
|
`${JSON.stringify(serializeSessionRecordForDisk(makeSessionRecord(record)), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function sessionFilePath(homeDir: string, acpxRecordId: string): string {
|
|
return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`);
|
|
}
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|