acpx/test/runtime.test.ts

506 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
AcpRuntimeError,
AcpxRuntime,
createAcpRuntime,
createAgentRegistry,
createFileSessionStore,
createRuntimeStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
type AcpRuntimeEvent,
type AcpSessionRecord,
} from "../src/runtime.js";
function createSessionRecord(overrides: Partial<AcpSessionRecord> = {}): AcpSessionRecord {
return {
schema: "acpx.session.v1",
acpxRecordId: "agent:codex:acp:test",
acpSessionId: "sid-1",
agentSessionId: "inner-1",
agentCommand: "codex --acp",
cwd: "/tmp/acpx",
name: "agent:codex:acp:test",
createdAt: "2026-04-05T00:00:00.000Z",
lastUsedAt: "2026-04-05T00:00:00.000Z",
lastSeq: 0,
eventLog: {
active_path: "",
segment_count: 0,
max_segment_bytes: 0,
max_segments: 0,
last_write_at: undefined,
last_write_error: null,
},
closed: false,
messages: [],
updated_at: "2026-04-05T00:00:00.000Z",
cumulative_token_usage: {},
request_token_usage: {},
acpx: {},
...overrides,
};
}
function emptyRuntimeEvents(): AsyncIterable<AcpRuntimeEvent> {
return {
[Symbol.asyncIterator](): AsyncIterator<AcpRuntimeEvent> {
return {
async next() {
return { done: true, value: undefined };
},
};
},
};
}
test("AcpxRuntime delegates session lifecycle to the runtime manager", async () => {
const encoded = encodeAcpxRuntimeHandleState({
name: "agent:codex:acp:test",
agent: "codex",
cwd: "/tmp/acpx",
mode: "persistent",
acpxRecordId: "agent:codex:acp:test",
backendSessionId: "sid-1",
agentSessionId: "inner-1",
});
assert.deepEqual(decodeAcpxRuntimeHandleState(encoded), {
name: "agent:codex:acp:test",
agent: "codex",
cwd: "/tmp/acpx",
mode: "persistent",
acpxRecordId: "agent:codex:acp:test",
backendSessionId: "sid-1",
agentSessionId: "inner-1",
});
const record = createSessionRecord();
let ensuredMode: string | undefined;
let turnMode: string | undefined;
let turnSessionMode: string | undefined;
let turnTimeoutMs: number | undefined;
let closedStreamRequestId: string | undefined;
let cancelCalls = 0;
let managerCancelCalls = 0;
let closeDiscardPersistentState: boolean | undefined;
const manager = {
ensureSession: async (input: { mode: string }) => {
ensuredMode = input.mode;
return record;
},
startTurn(input: { mode: string; sessionMode: string; timeoutMs?: number; requestId: string }) {
turnMode = input.mode;
turnSessionMode = input.sessionMode;
turnTimeoutMs = input.timeoutMs;
return {
requestId: input.requestId,
events: (async function* () {
yield { type: "text_delta" as const, text: "hello", stream: "output" as const };
})(),
result: Promise.resolve({
status: "completed" as const,
stopReason: "end_turn",
}),
cancel: async () => {
cancelCalls += 1;
},
closeStream: async (_input?: { reason?: string }) => {
closedStreamRequestId = input.requestId;
},
};
},
async *runTurn(input: {
mode: string;
sessionMode: string;
timeoutMs?: number;
requestId: string;
}) {
turnMode = input.mode;
turnSessionMode = input.sessionMode;
turnTimeoutMs = input.timeoutMs;
yield { type: "text_delta" as const, text: "hello", stream: "output" as const };
yield { type: "done" as const, stopReason: "end_turn" };
},
getStatus: async () => ({
summary: "status=ok",
acpxRecordId: record.acpxRecordId,
}),
setMode: async () => {},
setConfigOption: async () => {},
cancel: async () => {
managerCancelCalls += 1;
},
close: async (_handle: unknown, options?: { discardPersistentState?: boolean }) => {
closeDiscardPersistentState = options?.discardPersistentState;
},
};
const runtime = new AcpxRuntime(
{
cwd: "/tmp/acpx",
sessionStore: createFileSessionStore({ stateDir: "/tmp/acpx-state" }),
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
},
{
managerFactory: () => manager as never,
},
);
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:test",
agent: "codex",
mode: "oneshot",
});
assert.equal(ensuredMode, "oneshot");
assert.equal(handle.acpxRecordId, "agent:codex:acp:test");
assert.equal(handle.backendSessionId, "sid-1");
assert.equal(handle.agentSessionId, "inner-1");
const turn = runtime.startTurn({
handle,
text: "hello",
mode: "steer",
requestId: "req-1",
timeoutMs: 42,
});
const events = [];
for await (const event of turn.events) {
events.push(event);
}
const result = await turn.result;
assert.equal(turnMode, "steer");
assert.equal(turnSessionMode, "oneshot");
assert.equal(turnTimeoutMs, 42);
assert.deepEqual(events, [{ type: "text_delta", text: "hello", stream: "output" }]);
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
const legacyEvents: AcpRuntimeEvent[] = [];
for await (const event of runtime.runTurn({
handle,
text: "legacy",
mode: "prompt",
requestId: "req-legacy",
})) {
legacyEvents.push(event);
}
assert.deepEqual(legacyEvents, [
{ type: "text_delta", text: "hello", stream: "output" },
{ type: "done", stopReason: "end_turn" },
]);
await runtime.getStatus({ handle });
await runtime.setMode({ handle, mode: "architect" });
await runtime.setConfigOption({ handle, key: "approval", value: "manual" });
await runtime.cancel({ handle, reason: "legacy cancel" });
await turn.closeStream({ reason: "observer closed stream" });
await turn.cancel();
await runtime.close({ handle, reason: "test", discardPersistentState: true });
assert.equal(closedStreamRequestId, "req-1");
assert.equal(cancelCalls, 1);
assert.equal(managerCancelCalls, 1);
assert.equal(closeDiscardPersistentState, true);
});
test("createFileSessionStore persists records inside the provided state directory", async (t) => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-runtime-store-"));
t.after(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
const store = createFileSessionStore({ stateDir });
const record = createSessionRecord({
acpxRecordId: "agent:codex:acp:stored",
acpSessionId: "sid-stored",
});
await store.save(record);
const loaded = await store.load("agent:codex:acp:stored");
assert.equal(loaded?.acpxRecordId, "agent:codex:acp:stored");
assert.equal(loaded?.acpSessionId, "sid-stored");
assert.equal(
await fs
.readFile(path.join(stateDir, "sessions", "agent%3Acodex%3Aacp%3Astored.json"), "utf8")
.then((payload) => payload.includes('"schema": "acpx.session.v1"')),
true,
);
});
test("doctor reports backend unavailable probe failures and agent registry honors overrides", async () => {
const registry = createAgentRegistry({
overrides: {
codex: "codex-override --acp",
},
});
assert.equal(registry.resolve("codex"), "codex-override --acp");
const runtime = new AcpxRuntime(
{
cwd: "/workspace",
sessionStore: createFileSessionStore({ stateDir: "/tmp/acpx-runtime-doctor" }),
agentRegistry: registry,
permissionMode: "approve-reads",
},
{
probeRunner: async () => ({
ok: false,
message: "embedded ACP runtime probe failed",
details: ["agent=codex", "command=codex-override --acp"],
}),
},
);
const report = await runtime.doctor();
assert.equal(report.ok, false);
assert.equal(report.code, "ACP_BACKEND_UNAVAILABLE");
assert.deepEqual(report.details, ["agent=codex", "command=codex-override --acp"]);
});
test("doctor coerces probe detail values to strings", async () => {
const circular: Record<string, unknown> = { code: "BROKEN" };
circular.self = circular;
const runtime = new AcpxRuntime(
{
cwd: "/workspace",
sessionStore: createFileSessionStore({ stateDir: "/tmp/acpx-runtime-doctor-details" }),
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
},
{
probeRunner: async () => ({
ok: false,
message: "embedded ACP runtime probe failed",
details: ["agent=codex", new Error("spawn failed"), circular],
}),
},
);
const report = await runtime.doctor();
assert.equal(report.ok, false);
assert.equal(
report.details?.every((detail) => typeof detail === "string"),
true,
);
assert.match(report.details?.[1] ?? "", /spawn failed/);
assert.equal(report.details?.[2], '{"code":"BROKEN","self":"[Circular]"}');
});
test("AcpxRuntime validates required ensureSession inputs and runtime handles", async () => {
const runtime = createAcpRuntime({
cwd: "/workspace",
sessionStore: createFileSessionStore({ stateDir: "/tmp/acpx-runtime-invalid" }),
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
});
await assert.rejects(
async () =>
await runtime.ensureSession({
sessionKey: " ",
agent: "codex",
mode: "persistent",
}),
(error: unknown) => {
assert(error instanceof AcpRuntimeError);
assert.equal(error.code, "ACP_SESSION_INIT_FAILED");
assert.match(error.message, /session key is required/);
return true;
},
);
await assert.rejects(
async () =>
await runtime.ensureSession({
sessionKey: "agent:codex:acp:test",
agent: " ",
mode: "persistent",
}),
/ACP agent id is required/,
);
await assert.rejects(
async () =>
await runtime.getStatus({
handle: {
sessionKey: "agent:codex:acp:test",
backend: "acpx",
runtimeSessionName: " ",
},
}),
/runtimeSessionName is missing/,
);
});
test("AcpxRuntime falls back to plain runtimeSessionName handles and reuses a single manager instance", async () => {
const record = createSessionRecord({
acpxRecordId: "session-from-handle",
acpSessionId: "sid-handle",
agentSessionId: "inner-handle",
cwd: "/workspace",
});
let managerFactoryCalls = 0;
const manager = {
ensureSession: async () => record,
startTurn(input: { requestId: string }) {
return {
requestId: input.requestId,
events: emptyRuntimeEvents(),
result: Promise.resolve({
status: "completed" as const,
stopReason: "end_turn",
}),
cancel: async () => {},
closeStream: async () => {},
};
},
getStatus: async (handle: { acpxRecordId?: string; cwd?: string }) => ({
summary: `status=${handle.acpxRecordId}`,
acpxRecordId: handle.acpxRecordId,
details: {
cwd: handle.cwd,
},
}),
setMode: async () => {},
setConfigOption: async () => {},
closeStream: async () => {},
cancel: async () => {},
close: async () => {},
};
const runtime = new AcpxRuntime(
{
cwd: "/workspace",
sessionStore: createFileSessionStore({ stateDir: "/tmp/acpx-runtime-fallback" }),
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
},
{
managerFactory: () => {
managerFactoryCalls += 1;
return manager as never;
},
probeRunner: async () => ({
ok: true,
message: "embedded ACP runtime ready",
}),
},
);
await runtime.probeAvailability();
assert.equal(runtime.isHealthy(), true);
assert.deepEqual(await runtime.getCapabilities(), {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
});
const plainHandle = {
sessionKey: "agent:claude:acp:plain",
backend: "acpx",
runtimeSessionName: "plain-session-name",
cwd: "/workspace/plain",
acpxRecordId: "session-from-handle",
};
const status = await runtime.getStatus({ handle: plainHandle });
assert.equal(status.acpxRecordId, "session-from-handle");
assert.equal(status.details?.cwd, "/workspace/plain");
const turn = runtime.startTurn({
handle: plainHandle,
text: "hello",
mode: "prompt",
requestId: "req-plain",
});
const turnEvents = [];
for await (const event of turn.events) {
turnEvents.push(event);
}
const result = await turn.result;
assert.deepEqual(turnEvents, []);
assert.deepEqual(result, { status: "completed", stopReason: "end_turn" });
assert.equal(managerFactoryCalls, 1);
});
test("AcpxRuntime exposes advertised config option keys for resolved handles", async () => {
const encoded = encodeAcpxRuntimeHandleState({
name: "agent:codex:acp:test",
agent: "codex",
cwd: "/workspace",
mode: "persistent",
acpxRecordId: "agent:codex:acp:test",
backendSessionId: "sid-1",
agentSessionId: "inner-1",
});
const store = createFileSessionStore({ stateDir: "/tmp/acpx-runtime-config-options" });
await store.save(
createSessionRecord({
acpx: {
config_options: [
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
{
id: "model",
name: "Model",
type: "select",
currentValue: "fast",
options: [{ value: "fast", name: "Fast" }],
},
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "ask",
options: [{ value: "ask", name: "Ask" }],
},
],
},
}),
);
const runtime = new AcpxRuntime({
cwd: "/workspace",
sessionStore: store,
agentRegistry: createAgentRegistry(),
permissionMode: "approve-reads",
});
assert.deepEqual(
await runtime.getCapabilities({
handle: {
sessionKey: "ignored-session-key",
backend: "acpx",
runtimeSessionName: encoded,
},
}),
{
controls: ["session/set_mode", "session/set_config_option", "session/status"],
configOptionKeys: ["mode", "model"],
},
);
});
test("createRuntimeStore is an alias for the file-backed session store", async (t) => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-runtime-store-alias-"));
t.after(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
const store = createRuntimeStore({ stateDir });
const record = createSessionRecord({
acpxRecordId: "alias-record",
acpSessionId: "alias-sid",
});
await store.save(record);
const loaded = await store.load("alias-record");
assert.equal(loaded?.acpSessionId, "alias-sid");
});