mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
|
import {
|
|
cleanupMockRuntimeFixtures,
|
|
createMockRuntimeFixture,
|
|
NOOP_LOGGER,
|
|
readMockRuntimeLogEntries,
|
|
} from "./runtime-internals/test-fixtures.js";
|
|
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
|
|
|
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
sharedFixture = await createMockRuntimeFixture();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
sharedFixture = null;
|
|
await cleanupMockRuntimeFixtures();
|
|
});
|
|
|
|
describe("AcpxRuntime", () => {
|
|
it("passes the shared ACP adapter contract suite", async () => {
|
|
const fixture = await createMockRuntimeFixture();
|
|
await runAcpRuntimeAdapterContract({
|
|
createRuntime: async () => fixture.runtime,
|
|
agentId: "codex",
|
|
successPrompt: "contract-pass",
|
|
includeControlChecks: false,
|
|
assertSuccessEvents: (events) => {
|
|
expect(events.some((event) => event.type === "done")).toBe(true);
|
|
},
|
|
});
|
|
|
|
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
|
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
|
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
|
|
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
|
|
});
|
|
|
|
it("ensures sessions and streams prompt events", async () => {
|
|
const { runtime, logPath } = await createMockRuntimeFixture({ queueOwnerTtlSeconds: 180 });
|
|
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:123",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
expect(handle.backend).toBe("acpx");
|
|
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
|
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
|
|
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
|
|
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
|
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
|
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
|
|
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
|
|
|
|
const events = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "hello world",
|
|
mode: "prompt",
|
|
requestId: "req-test",
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "text_delta",
|
|
text: "thinking",
|
|
stream: "thought",
|
|
}),
|
|
]),
|
|
);
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "tool_call",
|
|
text: "run-tests (in_progress)",
|
|
}),
|
|
]),
|
|
);
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "text_delta",
|
|
text: "echo:hello world",
|
|
stream: "output",
|
|
}),
|
|
]),
|
|
);
|
|
expect(events).toContainEqual({
|
|
type: "done",
|
|
stopReason: "end_turn",
|
|
});
|
|
|
|
const logs = await readMockRuntimeLogEntries(logPath);
|
|
const ensure = logs.find((entry) => entry.kind === "ensure");
|
|
const prompt = logs.find((entry) => entry.kind === "prompt");
|
|
expect(ensure).toBeDefined();
|
|
expect(prompt).toBeDefined();
|
|
expect(prompt?.openclawShell).toBe("acp");
|
|
expect(Array.isArray(prompt?.args)).toBe(true);
|
|
const promptArgs = (prompt?.args as string[]) ?? [];
|
|
expect(promptArgs).toContain("--ttl");
|
|
expect(promptArgs).toContain("180");
|
|
expect(promptArgs).toContain("--approve-all");
|
|
});
|
|
|
|
it("preserves leading spaces across streamed text deltas", async () => {
|
|
const runtime = sharedFixture?.runtime;
|
|
expect(runtime).toBeDefined();
|
|
if (!runtime) {
|
|
throw new Error("shared runtime fixture missing");
|
|
}
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:space",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const textDeltas: string[] = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "split-spacing",
|
|
mode: "prompt",
|
|
requestId: "req-space",
|
|
})) {
|
|
if (event.type === "text_delta" && event.stream === "output") {
|
|
textDeltas.push(event.text);
|
|
}
|
|
}
|
|
|
|
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
|
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
|
|
|
// Keep the default queue-owner TTL assertion on a runTurn that already exists.
|
|
const activeLogPath = process.env.MOCK_ACPX_LOG;
|
|
expect(activeLogPath).toBeDefined();
|
|
const logs = await readMockRuntimeLogEntries(String(activeLogPath));
|
|
const prompt = logs.find(
|
|
(entry) =>
|
|
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space",
|
|
);
|
|
expect(prompt).toBeDefined();
|
|
const promptArgs = (prompt?.args as string[]) ?? [];
|
|
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
|
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
|
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
|
});
|
|
|
|
it("emits done once when ACP stream repeats stop reason responses", async () => {
|
|
const runtime = sharedFixture?.runtime;
|
|
expect(runtime).toBeDefined();
|
|
if (!runtime) {
|
|
throw new Error("shared runtime fixture missing");
|
|
}
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:double-done",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "double-done",
|
|
mode: "prompt",
|
|
requestId: "req-double-done",
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
const doneCount = events.filter((event) => event.type === "done").length;
|
|
expect(doneCount).toBe(1);
|
|
});
|
|
|
|
it("maps acpx error events into ACP runtime error events", async () => {
|
|
const runtime = sharedFixture?.runtime;
|
|
expect(runtime).toBeDefined();
|
|
if (!runtime) {
|
|
throw new Error("shared runtime fixture missing");
|
|
}
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:456",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "trigger-error",
|
|
mode: "prompt",
|
|
requestId: "req-err",
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toContainEqual({
|
|
type: "error",
|
|
message: "mock failure",
|
|
code: "-32000",
|
|
retryable: undefined,
|
|
});
|
|
});
|
|
|
|
it("supports cancel and close using encoded runtime handle state", async () => {
|
|
const { runtime, logPath, config } = await createMockRuntimeFixture();
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:claude:acp:789",
|
|
agent: "claude",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
|
expect(decoded?.name).toBe("agent:claude:acp:789");
|
|
|
|
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
|
|
|
|
await secondRuntime.cancel({ handle, reason: "test" });
|
|
await secondRuntime.close({ handle, reason: "test" });
|
|
|
|
const logs = await readMockRuntimeLogEntries(logPath);
|
|
const cancel = logs.find((entry) => entry.kind === "cancel");
|
|
const close = logs.find((entry) => entry.kind === "close");
|
|
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
|
|
expect(close?.sessionName).toBe("agent:claude:acp:789");
|
|
});
|
|
|
|
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
|
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:controls",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const capabilities = runtime.getCapabilities();
|
|
expect(capabilities.controls).toContain("session/set_mode");
|
|
expect(capabilities.controls).toContain("session/set_config_option");
|
|
expect(capabilities.controls).toContain("session/status");
|
|
|
|
await runtime.setMode({
|
|
handle,
|
|
mode: "plan",
|
|
});
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "model",
|
|
value: "openai-codex/gpt-5.3-codex",
|
|
});
|
|
const status = await runtime.getStatus({ handle });
|
|
const ensuredSessionName = "agent:codex:acp:controls";
|
|
|
|
expect(status.summary).toContain("status=alive");
|
|
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
|
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
|
|
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
|
|
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
|
expect(status.details?.status).toBe("alive");
|
|
expect(status.details?.pid).toBe(4242);
|
|
|
|
const logs = await readMockRuntimeLogEntries(logPath);
|
|
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
|
|
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
|
|
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
|
|
});
|
|
|
|
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
|
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
|
const handle = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:aborted",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const events = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle,
|
|
text: "should-not-run",
|
|
mode: "prompt",
|
|
requestId: "req-aborted",
|
|
signal: controller.signal,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
const logs = await readMockRuntimeLogEntries(logPath);
|
|
expect(events).toEqual([]);
|
|
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
|
|
});
|
|
|
|
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
|
|
const { runtime } = await createMockRuntimeFixture();
|
|
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
|
|
|
|
await runtime.probeAvailability();
|
|
expect(runtime.isHealthy()).toBe(true);
|
|
|
|
await expect(
|
|
runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:missing-cwd",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
cwd: missingCwd,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "ACP_SESSION_INIT_FAILED",
|
|
message: expect.stringContaining("working directory does not exist"),
|
|
});
|
|
expect(runtime.isHealthy()).toBe(true);
|
|
});
|
|
|
|
it("marks runtime unhealthy when command is missing", async () => {
|
|
const runtime = new AcpxRuntime(
|
|
{
|
|
command: "/definitely/missing/acpx",
|
|
allowPluginLocalInstall: false,
|
|
installCommand: "n/a",
|
|
cwd: process.cwd(),
|
|
permissionMode: "approve-reads",
|
|
nonInteractivePermissions: "fail",
|
|
strictWindowsCmdWrapper: true,
|
|
queueOwnerTtlSeconds: 0.1,
|
|
},
|
|
{ logger: NOOP_LOGGER },
|
|
);
|
|
|
|
await runtime.probeAvailability();
|
|
expect(runtime.isHealthy()).toBe(false);
|
|
});
|
|
|
|
it("logs ACPX spawn resolution once per command policy", async () => {
|
|
const { config } = await createMockRuntimeFixture();
|
|
const debugLogs: string[] = [];
|
|
const runtime = new AcpxRuntime(
|
|
{
|
|
...config,
|
|
strictWindowsCmdWrapper: true,
|
|
},
|
|
{
|
|
logger: {
|
|
...NOOP_LOGGER,
|
|
debug: (message: string) => {
|
|
debugLogs.push(message);
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
await runtime.probeAvailability();
|
|
|
|
const spawnLogs = debugLogs.filter((entry) => entry.startsWith("acpx spawn resolver:"));
|
|
expect(spawnLogs.length).toBe(1);
|
|
expect(spawnLogs[0]).toContain("mode=strict");
|
|
});
|
|
|
|
it("returns doctor report for missing command", async () => {
|
|
const runtime = new AcpxRuntime(
|
|
{
|
|
command: "/definitely/missing/acpx",
|
|
allowPluginLocalInstall: false,
|
|
installCommand: "n/a",
|
|
cwd: process.cwd(),
|
|
permissionMode: "approve-reads",
|
|
nonInteractivePermissions: "fail",
|
|
strictWindowsCmdWrapper: true,
|
|
queueOwnerTtlSeconds: 0.1,
|
|
},
|
|
{ logger: NOOP_LOGGER },
|
|
);
|
|
|
|
const report = await runtime.doctor();
|
|
expect(report.ok).toBe(false);
|
|
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
|
expect(report.installCommand).toContain("acpx");
|
|
});
|
|
});
|