mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 14:52:55 +00:00
* chore(lint): reduce underscore-dangle exceptions * chore(lint): reduce more underscore exceptions * chore(lint): remove underscore-dangle allow list * fix(lint): repair underscore cleanup regressions * test(lint): track version define suppression
1818 lines
60 KiB
TypeScript
1818 lines
60 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
AcpRuntimeError,
|
|
type AcpRuntime,
|
|
type AcpRuntimeEvent,
|
|
type AcpRuntimeTurn,
|
|
} from "../runtime-api.js";
|
|
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
|
import { AcpxRuntime, testing } from "./runtime.js";
|
|
|
|
type TestSessionStore = {
|
|
load(sessionId: string): Promise<Record<string, unknown> | undefined>;
|
|
save(record: Record<string, unknown>): Promise<void>;
|
|
};
|
|
|
|
const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND =
|
|
"env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main";
|
|
const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@0.13.0";
|
|
const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`;
|
|
const CODEX_ACP_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-close ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`;
|
|
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node "${path.resolve(
|
|
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
|
)}"`;
|
|
|
|
function makeRuntime(
|
|
baseStore: TestSessionStore,
|
|
options: Partial<ConstructorParameters<typeof AcpxRuntime>[0]> = {},
|
|
testOptions?: ConstructorParameters<typeof AcpxRuntime>[1],
|
|
): {
|
|
runtime: AcpxRuntime;
|
|
wrappedStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
|
delegate: {
|
|
cancel: AcpRuntime["cancel"];
|
|
close: AcpRuntime["close"];
|
|
ensureSession: AcpRuntime["ensureSession"];
|
|
startTurn: NonNullable<AcpRuntime["startTurn"]>;
|
|
runTurn: AcpRuntime["runTurn"];
|
|
getStatus: NonNullable<AcpRuntime["getStatus"]>;
|
|
setConfigOption: NonNullable<AcpRuntime["setConfigOption"]>;
|
|
isHealthy(): boolean;
|
|
probeAvailability(): Promise<void>;
|
|
};
|
|
bridgeSafeDelegate: {
|
|
close: AcpRuntime["close"];
|
|
ensureSession: AcpRuntime["ensureSession"];
|
|
getStatus: NonNullable<AcpRuntime["getStatus"]>;
|
|
setConfigOption: NonNullable<AcpRuntime["setConfigOption"]>;
|
|
isHealthy(): boolean;
|
|
probeAvailability(): Promise<void>;
|
|
};
|
|
} {
|
|
const runtime = new AcpxRuntime(
|
|
{
|
|
cwd: "/tmp",
|
|
sessionStore: baseStore,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "openclaw" ? "openclaw acp" : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
permissionMode: "approve-reads",
|
|
...options,
|
|
},
|
|
testOptions,
|
|
);
|
|
|
|
return {
|
|
runtime,
|
|
wrappedStore: (
|
|
runtime as unknown as {
|
|
sessionStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
|
}
|
|
).sessionStore,
|
|
delegate: (
|
|
runtime as unknown as {
|
|
delegate: {
|
|
cancel: AcpRuntime["cancel"];
|
|
close: AcpRuntime["close"];
|
|
ensureSession: AcpRuntime["ensureSession"];
|
|
startTurn: NonNullable<AcpRuntime["startTurn"]>;
|
|
runTurn: AcpRuntime["runTurn"];
|
|
getStatus: NonNullable<AcpRuntime["getStatus"]>;
|
|
setConfigOption: NonNullable<AcpRuntime["setConfigOption"]>;
|
|
isHealthy(): boolean;
|
|
probeAvailability(): Promise<void>;
|
|
};
|
|
}
|
|
).delegate,
|
|
bridgeSafeDelegate: (
|
|
runtime as unknown as {
|
|
bridgeSafeDelegate: {
|
|
close: AcpRuntime["close"];
|
|
ensureSession: AcpRuntime["ensureSession"];
|
|
getStatus: NonNullable<AcpRuntime["getStatus"]>;
|
|
setConfigOption: NonNullable<AcpRuntime["setConfigOption"]>;
|
|
isHealthy(): boolean;
|
|
probeAvailability(): Promise<void>;
|
|
};
|
|
}
|
|
).bridgeSafeDelegate,
|
|
};
|
|
}
|
|
|
|
function makeLeaseStore() {
|
|
const leases = new Map<string, Record<string, unknown>>();
|
|
return {
|
|
leases,
|
|
store: {
|
|
load: vi.fn(async (leaseId: string) => leases.get(leaseId) as never),
|
|
listOpen: vi.fn(async () => Array.from(leases.values()) as never),
|
|
save: vi.fn(async (lease: Record<string, unknown>) => {
|
|
leases.set(String(lease.leaseId), lease);
|
|
}),
|
|
markState: vi.fn(async (leaseId: string, state: string) => {
|
|
const lease = leases.get(leaseId);
|
|
if (lease) {
|
|
lease.state = state;
|
|
}
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function readFirstEnsureSessionInput(ensure: {
|
|
mock: { calls: Array<Array<unknown>> };
|
|
}): Parameters<AcpRuntime["ensureSession"]>[0] {
|
|
const [call] = ensure.mock.calls;
|
|
if (!call) {
|
|
throw new Error("Expected ensureSession to be called");
|
|
}
|
|
const [input] = call;
|
|
if (typeof input !== "object" || input === null) {
|
|
throw new Error("Expected ensureSession to be called with an input object");
|
|
}
|
|
return input as Parameters<AcpRuntime["ensureSession"]>[0];
|
|
}
|
|
|
|
describe("AcpxRuntime fresh reset wrapper", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("rejects unsupported runtime session modes with a clear AcpRuntimeError (issue #73071)", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const ensureSpy = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:claude:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "claude",
|
|
});
|
|
|
|
for (const badMode of ["run", "session", "", undefined, null, 0]) {
|
|
let error: unknown;
|
|
try {
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:claude:acp:test",
|
|
agent: "claude",
|
|
mode: badMode as never,
|
|
});
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(AcpRuntimeError);
|
|
const acpError = error as AcpRuntimeError;
|
|
expect(acpError.name).toBe("AcpRuntimeError");
|
|
expect(acpError.code).toBe("ACP_INVALID_RUNTIME_OPTION");
|
|
expect(acpError.message).toBe(
|
|
`Unsupported ACP runtime session mode ${JSON.stringify(badMode)}. Expected one of: persistent, oneshot.`,
|
|
);
|
|
}
|
|
|
|
expect(ensureSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exposes assertSupportedRuntimeSessionMode as a typed guard", () => {
|
|
expect(testing.assertSupportedRuntimeSessionMode("persistent")).toBeUndefined();
|
|
expect(testing.assertSupportedRuntimeSessionMode("oneshot")).toBeUndefined();
|
|
expect(() => testing.assertSupportedRuntimeSessionMode("run" as never)).toThrow(
|
|
AcpRuntimeError,
|
|
);
|
|
});
|
|
|
|
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "codex",
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "openai-codex/gpt-5.4",
|
|
});
|
|
|
|
expect(readFirstEnsureSessionInput(ensure)).toEqual({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "gpt-5.4",
|
|
});
|
|
});
|
|
|
|
it("leaves Codex ACP startup defaults alone when no model or thinking is provided", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "codex",
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
const ensureInput = readFirstEnsureSessionInput(ensure);
|
|
expect(ensureInput).toEqual({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
expect(ensureInput).not.toHaveProperty("model");
|
|
expect(ensureInput).not.toHaveProperty("thinking");
|
|
});
|
|
|
|
it("adds Codex wrapper stderr tail to generic session initialization failures", async () => {
|
|
const wrapperRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-"));
|
|
const leaseStore = makeLeaseStore();
|
|
const wrapperCommand = `node "${path.join(wrapperRoot, "codex-acp-wrapper.mjs")}"`;
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: wrapperRoot,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? wrapperCommand : agentName),
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "ensureSession").mockImplementation(async () => {
|
|
const leaseId = String(Array.from(leaseStore.leases.values())[0]?.leaseId);
|
|
await fs.writeFile(
|
|
path.join(wrapperRoot, `codex-acp-wrapper.stderr.${leaseId}.log`),
|
|
"noise\nUnhandled error during session/new: deployment missing token=[REDACTED] sk-testsecret1234567890\n",
|
|
"utf8",
|
|
);
|
|
throw new Error("Internal error");
|
|
});
|
|
|
|
const outcome = await runtime
|
|
.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "oneshot",
|
|
})
|
|
.then(
|
|
() => ({ status: "resolved" as const }),
|
|
(error) => ({ status: "rejected" as const, error }),
|
|
);
|
|
|
|
expect(outcome.status).toBe("rejected");
|
|
if (outcome.status !== "rejected") {
|
|
return;
|
|
}
|
|
expect(outcome.error).toMatchObject({
|
|
name: "AcpRuntimeError",
|
|
code: "ACP_SESSION_INIT_FAILED",
|
|
message: expect.stringContaining("deployment missing"),
|
|
});
|
|
expect(outcome.error.message).not.toContain("sk-testsecret1234567890");
|
|
});
|
|
|
|
it("adds Codex wrapper stderr tail to generic first-turn failures", async () => {
|
|
const wrapperRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-"));
|
|
await fs.writeFile(
|
|
path.join(wrapperRoot, "codex-acp-wrapper.stderr.lease-turn.log"),
|
|
"Unhandled error during turn: upstream model returned 404\n",
|
|
"utf8",
|
|
);
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_WRAPPER_COMMAND,
|
|
openclawLeaseId: "lease-turn",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawWrapperRoot: wrapperRoot,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "runTurn").mockImplementation(async function* () {
|
|
const emptyAsyncIterable: AsyncIterable<never> = {
|
|
[Symbol.asyncIterator]: () => ({
|
|
next: async () => ({ done: true, value: undefined as never }),
|
|
}),
|
|
};
|
|
yield* emptyAsyncIterable;
|
|
throw new Error("Internal error");
|
|
});
|
|
|
|
await expect(async () => {
|
|
for await (const eventValue of runtime.runTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
})) {
|
|
// no-op
|
|
}
|
|
}).rejects.toMatchObject({
|
|
name: "AcpRuntimeError",
|
|
code: "ACP_TURN_FAILED",
|
|
message: expect.stringContaining("upstream model returned 404"),
|
|
});
|
|
});
|
|
|
|
it("adds Codex wrapper stderr tail to generic terminal turn error events", async () => {
|
|
const wrapperRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-"));
|
|
await fs.writeFile(
|
|
path.join(wrapperRoot, "codex-acp-wrapper.stderr.lease-turn-event.log"),
|
|
"Unhandled error during turn: profile missing OPENAI_API_KEY\n",
|
|
"utf8",
|
|
);
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_WRAPPER_COMMAND,
|
|
openclawLeaseId: "lease-turn-event",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawWrapperRoot: wrapperRoot,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "runTurn").mockImplementation(async function* () {
|
|
yield {
|
|
type: "error",
|
|
message: "Internal error",
|
|
retryable: false,
|
|
};
|
|
});
|
|
|
|
const events: AcpRuntimeEvent[] = [];
|
|
for await (const event of runtime.runTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "error",
|
|
code: "ACP_TURN_FAILED",
|
|
message: expect.stringContaining("profile missing OPENAI_API_KEY"),
|
|
retryable: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("adds Codex wrapper stderr tail to generic startTurn failure results", async () => {
|
|
const wrapperRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-"));
|
|
await fs.writeFile(
|
|
path.join(wrapperRoot, "codex-acp-wrapper.stderr.lease-start-turn.log"),
|
|
"Unhandled error during turn: adapter disconnected after progress\n",
|
|
"utf8",
|
|
);
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_WRAPPER_COMMAND,
|
|
openclawLeaseId: "lease-start-turn",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawWrapperRoot: wrapperRoot,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "startTurn").mockImplementation(
|
|
(input): AcpRuntimeTurn => ({
|
|
requestId: input.requestId,
|
|
events: (async function* () {
|
|
yield {
|
|
type: "text_delta" as const,
|
|
stream: "output" as const,
|
|
text: "Vou mapear o fluxo real primeiro...",
|
|
};
|
|
})(),
|
|
result: Promise.resolve({
|
|
status: "failed" as const,
|
|
error: {
|
|
message: "Internal error",
|
|
retryable: false,
|
|
},
|
|
}),
|
|
cancel: vi.fn(async () => {}),
|
|
closeStream: vi.fn(async () => {}),
|
|
}),
|
|
);
|
|
|
|
const turn = runtime.startTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
});
|
|
const events: AcpRuntimeEvent[] = [];
|
|
for await (const event of turn.events) {
|
|
events.push(event);
|
|
}
|
|
|
|
await expect(turn.result).resolves.toMatchObject({
|
|
status: "failed",
|
|
error: {
|
|
code: "ACP_TURN_FAILED",
|
|
message: expect.stringContaining("adapter disconnected after progress"),
|
|
retryable: false,
|
|
},
|
|
});
|
|
expect(events).toEqual([
|
|
{
|
|
type: "text_delta",
|
|
stream: "output",
|
|
text: "Vou mapear o fluxo real primeiro...",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("adds Codex wrapper stderr tail when startTurn creation throws", async () => {
|
|
const wrapperRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-"));
|
|
await fs.writeFile(
|
|
path.join(wrapperRoot, "codex-acp-wrapper.stderr.lease-start-turn-create.log"),
|
|
"Unhandled error during turn: adapter failed before returning turn\n",
|
|
"utf8",
|
|
);
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_WRAPPER_COMMAND,
|
|
openclawLeaseId: "lease-start-turn-create",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawWrapperRoot: wrapperRoot,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "startTurn").mockImplementation(() => {
|
|
throw new Error("Internal error");
|
|
});
|
|
|
|
const turn = runtime.startTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
});
|
|
|
|
await expect(turn.result).rejects.toMatchObject({
|
|
name: "AcpRuntimeError",
|
|
code: "ACP_TURN_FAILED",
|
|
message: expect.stringContaining("adapter failed before returning turn"),
|
|
});
|
|
});
|
|
|
|
it("disables delegate prompt timeout for OpenClaw-managed turns", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
timeoutMs: 1,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
const runTurn = vi.spyOn(delegate, "runTurn").mockImplementation(async function* () {
|
|
yield { type: "done" };
|
|
});
|
|
const startTurn = vi.spyOn(delegate, "startTurn").mockImplementation(
|
|
(input): AcpRuntimeTurn => ({
|
|
requestId: input.requestId,
|
|
events: (async function* () {
|
|
yield { type: "done" as const, stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({
|
|
status: "completed" as const,
|
|
stopReason: "end_turn",
|
|
}),
|
|
cancel: vi.fn(async () => {}),
|
|
closeStream: vi.fn(async () => {}),
|
|
}),
|
|
);
|
|
|
|
for await (const eventValue of runtime.runTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
})) {
|
|
// no-op
|
|
}
|
|
|
|
expect(runTurn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
timeoutMs: 0,
|
|
}),
|
|
);
|
|
|
|
const turn = runtime.startTurn({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
},
|
|
text: "Reply exactly OK",
|
|
mode: "prompt",
|
|
requestId: "turn-2",
|
|
});
|
|
for await (const eventValue of turn.events) {
|
|
// no-op
|
|
}
|
|
await turn.result;
|
|
|
|
expect(startTurn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
timeoutMs: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not normalize model startup for non-Codex ACP agents", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "main" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["main", "codex", "openclaw"],
|
|
},
|
|
});
|
|
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:main:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "main",
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:main:acp:test",
|
|
agent: "main",
|
|
mode: "persistent",
|
|
model: "openai-codex/gpt-5.5",
|
|
});
|
|
|
|
expect(readFirstEnsureSessionInput(ensure)).toEqual({
|
|
sessionKey: "agent:main:acp:test",
|
|
agent: "main",
|
|
mode: "persistent",
|
|
model: "openai-codex/gpt-5.5",
|
|
});
|
|
});
|
|
|
|
it("injects Codex ACP startup config into the scoped registry", () => {
|
|
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
|
|
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);
|
|
expect(
|
|
testing.appendCodexAcpConfigOverrides(CODEX_ACP_COMMAND, {
|
|
model: "gpt-5.4",
|
|
reasoningEffort: "medium",
|
|
}),
|
|
).toBe(
|
|
"npx @zed-industries/codex-acp@0.13.0 -c model=gpt-5.4 -c model_reasoning_effort=medium",
|
|
);
|
|
expect(testing.isCodexAcpCommand("openclaw acp")).toBe(false);
|
|
});
|
|
|
|
it("passes gpt-5.5 Codex ACP startup through instead of blocking it", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "codex",
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "openai-codex/gpt-5.5",
|
|
});
|
|
|
|
expect(readFirstEnsureSessionInput(ensure)).toEqual({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "gpt-5.5",
|
|
});
|
|
});
|
|
|
|
it("maps explicit Codex ACP thinking to startup reasoning effort", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "codex",
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "openai-codex/gpt-5.4",
|
|
thinking: "x-high",
|
|
});
|
|
|
|
expect(readFirstEnsureSessionInput(ensure)).toEqual({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
model: "gpt-5.4/xhigh",
|
|
thinking: "x-high",
|
|
});
|
|
});
|
|
|
|
it("normalizes Codex ACP model config controls to adapter ids", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "model",
|
|
value: "openai-codex/gpt-5.4",
|
|
});
|
|
|
|
expect(setConfigOption).toHaveBeenNthCalledWith(1, {
|
|
handle,
|
|
key: "model",
|
|
value: "gpt-5.4",
|
|
});
|
|
expect(setConfigOption).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("normalizes Codex ACP slash reasoning suffixes to config controls", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "model",
|
|
value: "openai-codex/gpt-5.4/high",
|
|
});
|
|
|
|
expect(setConfigOption).toHaveBeenNthCalledWith(1, {
|
|
handle,
|
|
key: "model",
|
|
value: "gpt-5.4",
|
|
});
|
|
expect(setConfigOption).toHaveBeenNthCalledWith(2, {
|
|
handle,
|
|
key: "reasoning_effort",
|
|
value: "high",
|
|
});
|
|
});
|
|
|
|
it("normalizes Codex ACP thinking config controls to reasoning effort", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "thinking",
|
|
value: "minimal",
|
|
});
|
|
|
|
expect(setConfigOption).toHaveBeenCalledWith({
|
|
handle,
|
|
key: "reasoning_effort",
|
|
value: "low",
|
|
});
|
|
});
|
|
|
|
it("ignores unsupported Codex ACP timeout config controls", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
agentCommand: CODEX_ACP_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:test",
|
|
acpxRecordId: "agent:codex:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "timeout",
|
|
value: "60000",
|
|
});
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "Timeout_Seconds",
|
|
value: "60",
|
|
});
|
|
|
|
expect(setConfigOption).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores unsupported claude-agent-acp timeout config controls", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:claude:acp:test",
|
|
agentCommand: "npx @agentclientprotocol/claude-agent-acp",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:claude:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:claude:acp:test",
|
|
acpxRecordId: "agent:claude:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "timeout",
|
|
value: "60",
|
|
});
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "Timeout_Seconds",
|
|
value: "60",
|
|
});
|
|
|
|
expect(setConfigOption).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still forwards non-timeout config controls for claude-agent-acp", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:claude:acp:test",
|
|
agentCommand: "npx @agentclientprotocol/claude-agent-acp",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { runtime, delegate } = makeRuntime(baseStore);
|
|
const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined);
|
|
const handle: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]["handle"] = {
|
|
sessionKey: "agent:claude:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:claude:acp:test",
|
|
acpxRecordId: "agent:claude:acp:test",
|
|
};
|
|
|
|
await runtime.setConfigOption({
|
|
handle,
|
|
key: "model",
|
|
value: "claude-sonnet-4.6",
|
|
});
|
|
|
|
expect(setConfigOption).toHaveBeenCalledOnce();
|
|
expect(setConfigOption).toHaveBeenCalledWith({
|
|
handle,
|
|
key: "model",
|
|
value: "claude-sonnet-4.6",
|
|
});
|
|
});
|
|
|
|
it("recognizes claude-agent-acp commands", () => {
|
|
expect(testing.isClaudeAcpCommand("npx @agentclientprotocol/claude-agent-acp")).toBe(true);
|
|
expect(testing.isClaudeAcpCommand("npx -y @agentclientprotocol/claude-agent-acp@0.33.1")).toBe(
|
|
true,
|
|
);
|
|
expect(testing.isClaudeAcpCommand("claude-agent-acp")).toBe(true);
|
|
expect(testing.isClaudeAcpCommand("claude-agent-acp.exe")).toBe(true);
|
|
expect(
|
|
testing.isClaudeAcpCommand(`node "/tmp/openclaw/acpx/claude-agent-acp-wrapper.mjs"`),
|
|
).toBe(true);
|
|
expect(
|
|
testing.isClaudeAcpCommand(
|
|
`node.exe "C:/Users/runner/AppData/Local/Temp/openclaw/acpx/claude-agent-acp-wrapper.mjs"`,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
testing.isClaudeAcpCommand(
|
|
`Node.EXE "C:/Users/runner/AppData/Local/Temp/openclaw/acpx/claude-agent-acp-wrapper.mjs"`,
|
|
),
|
|
).toBe(true);
|
|
expect(testing.isClaudeAcpCommand("openclaw acp")).toBe(false);
|
|
expect(testing.isClaudeAcpCommand("npx @zed-industries/codex-acp")).toBe(false);
|
|
});
|
|
|
|
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, wrappedStore } = makeRuntime(baseStore);
|
|
|
|
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
|
acpxRecordId: "stale",
|
|
});
|
|
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
|
|
|
await runtime.prepareFreshSession({
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
});
|
|
|
|
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
|
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
|
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
|
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
|
|
|
await wrappedStore.save({
|
|
acpxRecordId: "fresh-record",
|
|
name: "agent:codex:acp:binding:test",
|
|
} as never);
|
|
|
|
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
|
acpxRecordId: "stale",
|
|
});
|
|
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("marks the session fresh after discardPersistentState close", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
|
|
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
|
|
|
await runtime.close({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "new-in-place-reset",
|
|
discardPersistentState: true,
|
|
});
|
|
|
|
expect(close).toHaveBeenCalledWith({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "new-in-place-reset",
|
|
discardPersistentState: true,
|
|
});
|
|
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
|
expect(baseStore.load).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
pid: 900,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
const { runtime, delegate } = makeRuntime(
|
|
baseStore,
|
|
{
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
},
|
|
{
|
|
openclawProcessCleanup: {
|
|
listProcesses: vi.fn(async () => [
|
|
{
|
|
pid: 900,
|
|
ppid: 1,
|
|
command: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
},
|
|
{
|
|
pid: 901,
|
|
ppid: 900,
|
|
command:
|
|
"node /tmp/openclaw/plugin-runtime-deps/node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
|
},
|
|
]),
|
|
killProcess: vi.fn((pid, signal) => {
|
|
killed.push({ pid, signal });
|
|
}),
|
|
sleep: vi.fn(async () => {}),
|
|
},
|
|
},
|
|
);
|
|
vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
|
|
|
await runtime.close({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "user-close",
|
|
});
|
|
|
|
expect(killed.slice(0, 2)).toEqual([
|
|
{ pid: 901, signal: "SIGTERM" },
|
|
{ pid: 900, signal: "SIGTERM" },
|
|
]);
|
|
});
|
|
|
|
it("records ACPX process leases without persisting lease-specific agent commands", async () => {
|
|
const savedRecords: Record<string, unknown>[] = [];
|
|
const launchCommands: string[] = [];
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async (record) => {
|
|
savedRecords.push(record);
|
|
}),
|
|
};
|
|
const leaseStore = makeLeaseStore();
|
|
const { runtime, delegate, wrappedStore } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => {
|
|
const command = (
|
|
runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } }
|
|
).scopedAgentRegistry.resolve("codex");
|
|
launchCommands.push(command);
|
|
await wrappedStore.save({
|
|
name: input.sessionKey,
|
|
agentCommand: command,
|
|
pid: 777,
|
|
});
|
|
return {
|
|
sessionKey: input.sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: input.sessionKey,
|
|
};
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(leaseStore.store.save).toHaveBeenCalledTimes(2);
|
|
const leases = Array.from(leaseStore.leases.values());
|
|
expect(leases).toHaveLength(1);
|
|
const lease = leases[0];
|
|
expect(lease?.gatewayInstanceId).toBe("gateway-test");
|
|
expect(lease?.sessionKey).toBe("agent:codex:acp:binding:test");
|
|
expect(lease?.rootPid).toBe(777);
|
|
expect(lease?.state).toBe("open");
|
|
expect(lease?.wrapperPath).toBe("/tmp/openclaw/acpx/codex-acp-wrapper.mjs");
|
|
expect(launchCommands[0]).toContain("OPENCLAW_ACPX_LEASE_ID=");
|
|
expect(launchCommands[0]).toContain("OPENCLAW_GATEWAY_INSTANCE_ID=gateway-test");
|
|
expect(savedRecords[0]?.agentCommand).toBe(CODEX_ACP_WRAPPER_COMMAND);
|
|
expect(savedRecords[0]?.openclawGatewayInstanceId).toBe("gateway-test");
|
|
expect(savedRecords[0]?.openclawLeaseId).toBe(lease?.leaseId);
|
|
});
|
|
|
|
it("does not create launch leases for direct plugin-local ACP adapter commands", async () => {
|
|
const launchCommands: string[] = [];
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const leaseStore = makeLeaseStore();
|
|
const { runtime, delegate, wrappedStore } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? LOCAL_NODE_MODULES_CODEX_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => {
|
|
const command = (
|
|
runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } }
|
|
).scopedAgentRegistry.resolve("codex");
|
|
launchCommands.push(command);
|
|
await wrappedStore.save({
|
|
name: input.sessionKey,
|
|
agentCommand: command,
|
|
pid: 777,
|
|
});
|
|
return {
|
|
sessionKey: input.sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: input.sessionKey,
|
|
};
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(leaseStore.store.save).not.toHaveBeenCalled();
|
|
expect(launchCommands).toEqual([LOCAL_NODE_MODULES_CODEX_COMMAND]);
|
|
});
|
|
|
|
it("keeps reusable persistent ACP launch commands stable across ensures", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
name: "agent:codex:acp:binding:test",
|
|
acpxRecordId: "record-1",
|
|
acpSessionId: "session-1",
|
|
agentCommand: CODEX_ACP_WRAPPER_COMMAND,
|
|
cwd: "/tmp",
|
|
closed: false,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const leaseStore = makeLeaseStore();
|
|
const { runtime, delegate } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "codex" ? CODEX_ACP_WRAPPER_COMMAND : agentName,
|
|
list: () => ["codex"],
|
|
},
|
|
});
|
|
const resolvedCommands: string[] = [];
|
|
vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => {
|
|
resolvedCommands.push(
|
|
(
|
|
runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } }
|
|
).scopedAgentRegistry.resolve("codex"),
|
|
);
|
|
return {
|
|
sessionKey: input.sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: input.sessionKey,
|
|
};
|
|
});
|
|
|
|
await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(resolvedCommands).toEqual([CODEX_ACP_WRAPPER_COMMAND]);
|
|
expect(leaseStore.store.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("merges sidecar lease ids into loaded ACPX session records", async () => {
|
|
const leaseStore = makeLeaseStore();
|
|
leaseStore.leases.set("lease-loaded", {
|
|
leaseId: "lease-loaded",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 777,
|
|
commandHash: "hash",
|
|
startedAt: 1,
|
|
state: "open",
|
|
});
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
name: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
pid: 777,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { wrappedStore } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
});
|
|
|
|
const loadedRecord = await wrappedStore.load("agent:codex:acp:binding:test");
|
|
expect(loadedRecord?.openclawGatewayInstanceId).toBe("gateway-test");
|
|
expect(loadedRecord?.openclawLeaseId).toBe("lease-loaded");
|
|
});
|
|
|
|
it("merges the lease for the current ACPX session process when old leases exist", async () => {
|
|
const leaseStore = makeLeaseStore();
|
|
leaseStore.leases.set("lease-old", {
|
|
leaseId: "lease-old",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 700,
|
|
commandHash: "hash",
|
|
startedAt: 1,
|
|
state: "open",
|
|
});
|
|
leaseStore.leases.set("lease-current", {
|
|
leaseId: "lease-current",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 777,
|
|
commandHash: "hash",
|
|
startedAt: 2,
|
|
state: "open",
|
|
});
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
name: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
pid: 777,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const { wrappedStore } = makeRuntime(baseStore, {
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
});
|
|
|
|
const loadedRecord = await wrappedStore.load("agent:codex:acp:binding:test");
|
|
expect(loadedRecord?.openclawGatewayInstanceId).toBe("gateway-test");
|
|
expect(loadedRecord?.openclawLeaseId).toBe("lease-current");
|
|
});
|
|
|
|
it("uses matching leases before legacy pid cleanup on close", async () => {
|
|
const leaseStore = makeLeaseStore();
|
|
leaseStore.leases.set("lease-close", {
|
|
leaseId: "lease-close",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 930,
|
|
commandHash: "hash",
|
|
startedAt: 1,
|
|
state: "open",
|
|
});
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
openclawLeaseId: "lease-close",
|
|
pid: 930,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
const { runtime, delegate } = makeRuntime(
|
|
baseStore,
|
|
{
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
},
|
|
{
|
|
openclawProcessCleanup: {
|
|
listProcesses: vi.fn(async () => [
|
|
{
|
|
pid: 930,
|
|
ppid: 1,
|
|
command: CODEX_ACP_WRAPPER_COMMAND_WITH_LEASE,
|
|
},
|
|
{ pid: 931, ppid: 930, command: "node child.js" },
|
|
]),
|
|
killProcess: vi.fn((pid, signal) => {
|
|
killed.push({ pid, signal });
|
|
}),
|
|
sleep: vi.fn(async () => {}),
|
|
},
|
|
},
|
|
);
|
|
vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
|
|
|
await runtime.close({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "user-close",
|
|
});
|
|
|
|
expect(killed.slice(0, 2)).toEqual([
|
|
{ pid: 931, signal: "SIGTERM" },
|
|
{ pid: 930, signal: "SIGTERM" },
|
|
]);
|
|
expect(leaseStore.store.markState).toHaveBeenCalledWith("lease-close", "closing");
|
|
expect(leaseStore.store.markState).toHaveBeenLastCalledWith("lease-close", "closed");
|
|
});
|
|
|
|
it("closes the current process lease when the saved lease id is stale", async () => {
|
|
const leaseStore = makeLeaseStore();
|
|
leaseStore.leases.set("lease-old", {
|
|
leaseId: "lease-old",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 930,
|
|
commandHash: "hash",
|
|
startedAt: 1,
|
|
state: "open",
|
|
});
|
|
leaseStore.leases.set("lease-current", {
|
|
leaseId: "lease-current",
|
|
gatewayInstanceId: "gateway-test",
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
wrapperRoot: "/tmp/openclaw/acpx",
|
|
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
|
rootPid: 940,
|
|
commandHash: "hash",
|
|
startedAt: 2,
|
|
state: "open",
|
|
});
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
openclawLeaseId: "lease-old",
|
|
pid: 940,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
const { runtime, delegate } = makeRuntime(
|
|
baseStore,
|
|
{
|
|
openclawGatewayInstanceId: "gateway-test",
|
|
openclawProcessLeaseStore: leaseStore.store,
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
},
|
|
{
|
|
openclawProcessCleanup: {
|
|
listProcesses: vi.fn(async () => [
|
|
{
|
|
pid: 930,
|
|
ppid: 1,
|
|
command: `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-old ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`,
|
|
},
|
|
{
|
|
pid: 940,
|
|
ppid: 1,
|
|
command: `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-current ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`,
|
|
},
|
|
{ pid: 941, ppid: 940, command: "node child.js" },
|
|
]),
|
|
killProcess: vi.fn((pid, signal) => {
|
|
killed.push({ pid, signal });
|
|
}),
|
|
sleep: vi.fn(async () => {}),
|
|
},
|
|
},
|
|
);
|
|
vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
|
|
|
await runtime.close({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "user-close",
|
|
});
|
|
|
|
expect(killed.slice(0, 2)).toEqual([
|
|
{ pid: 941, signal: "SIGTERM" },
|
|
{ pid: 940, signal: "SIGTERM" },
|
|
]);
|
|
expect(leaseStore.store.markState.mock.calls).toEqual([
|
|
["lease-current", "closing"],
|
|
["lease-current", "closed"],
|
|
]);
|
|
});
|
|
|
|
it("does not clean up a stale close pid reused by another wrapper root", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
pid: 920,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
const { runtime, delegate } = makeRuntime(
|
|
baseStore,
|
|
{
|
|
openclawWrapperRoot: "/tmp/openclaw/acpx",
|
|
},
|
|
{
|
|
openclawProcessCleanup: {
|
|
listProcesses: vi.fn(async () => [
|
|
{
|
|
pid: 920,
|
|
ppid: 1,
|
|
command: 'node "/tmp/other-gateway/acpx/codex-acp-wrapper.mjs"',
|
|
},
|
|
]),
|
|
killProcess: vi.fn((pid, signal) => {
|
|
killed.push({ pid, signal });
|
|
}),
|
|
sleep: vi.fn(async () => {}),
|
|
},
|
|
},
|
|
);
|
|
vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
|
|
|
await runtime.close({
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
reason: "user-close",
|
|
});
|
|
|
|
expect(killed).toStrictEqual([]);
|
|
});
|
|
|
|
it("does not tear down reusable ACPX sessions after cancel", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:codex:acp:binding:test",
|
|
agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"',
|
|
processId: "910",
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
const listProcesses = vi.fn(async () => {
|
|
throw new Error("process listing should not run on cancel");
|
|
});
|
|
const { runtime, delegate } = makeRuntime(
|
|
baseStore,
|
|
{},
|
|
{
|
|
openclawProcessCleanup: {
|
|
listProcesses,
|
|
killProcess: vi.fn((pid, signal) => {
|
|
killed.push({ pid, signal });
|
|
}),
|
|
sleep: vi.fn(async () => {}),
|
|
},
|
|
},
|
|
);
|
|
const cancel = vi.spyOn(delegate, "cancel").mockResolvedValue(undefined);
|
|
|
|
const input = {
|
|
handle: {
|
|
sessionKey: "agent:codex:acp:binding:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:codex:acp:binding:test",
|
|
},
|
|
} satisfies Parameters<AcpRuntime["cancel"]>[0];
|
|
|
|
await runtime.cancel(input);
|
|
|
|
expect(cancel).toHaveBeenCalledWith(input);
|
|
expect(listProcesses).not.toHaveBeenCalled();
|
|
expect(killed).toStrictEqual([]);
|
|
});
|
|
|
|
it("routes openclaw ensureSession through the bridge-safe delegate when MCP servers are configured", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
agent: "openclaw",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("bridge");
|
|
expect(bridgeEnsure).toHaveBeenCalledOnce();
|
|
expect(defaultEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes non-openclaw sessions through the default delegate", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("default");
|
|
expect(defaultEnsure).toHaveBeenCalledOnce();
|
|
expect(bridgeEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes handle-based follow-up calls for openclaw sessions through the bridge-safe delegate", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
});
|
|
const defaultStatus = vi.spyOn(delegate, "getStatus").mockResolvedValue({
|
|
summary: "default",
|
|
});
|
|
const bridgeStatus = vi.spyOn(bridgeSafeDelegate, "getStatus").mockResolvedValue({
|
|
summary: "bridge",
|
|
});
|
|
const handle: Parameters<NonNullable<AcpRuntime["getStatus"]>>[0]["handle"] = {
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "openclaw-session-handle",
|
|
};
|
|
|
|
const status = await runtime.getStatus({ handle });
|
|
|
|
expect(status.summary).toBe("bridge");
|
|
expect(bridgeStatus).toHaveBeenCalledWith({ handle });
|
|
expect(defaultStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps MCP-enabled routing when the openclaw agent is overridden to a non-bridge adapter", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "openclaw" ? "codex" : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
agent: "openclaw",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("default");
|
|
expect(defaultEnsure).toHaveBeenCalledOnce();
|
|
expect(bridgeEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the bridge-safe delegate for any agent mapped to the openclaw bridge command", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "codex" ? "openclaw acp" : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:codex:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:codex:acp:test",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("bridge");
|
|
expect(bridgeEnsure).toHaveBeenCalledOnce();
|
|
expect(defaultEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the bridge-safe delegate for documented env-wrapped openclaw bridge commands", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "openclaw" ? DOCUMENTED_OPENCLAW_BRIDGE_COMMAND : agentName,
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
agent: "openclaw",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("bridge");
|
|
expect(bridgeEnsure).toHaveBeenCalledOnce();
|
|
expect(defaultEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the bridge-safe delegate for local node openclaw entrypoints", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "openclaw" ? "env OPENCLAW_HIDE_BANNER=1 node openclaw.mjs acp" : agentName,
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultEnsure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "default",
|
|
});
|
|
const bridgeEnsure = vi.spyOn(bridgeSafeDelegate, "ensureSession").mockResolvedValue({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "bridge",
|
|
});
|
|
|
|
const result = await runtime.ensureSession({
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
agent: "openclaw",
|
|
mode: "persistent",
|
|
});
|
|
|
|
expect(result.runtimeSessionName).toBe("bridge");
|
|
expect(bridgeEnsure).toHaveBeenCalledOnce();
|
|
expect(defaultEnsure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes follow-up calls by persisted agent command before current config", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => ({
|
|
acpxRecordId: "agent:openclaw:acp:test",
|
|
agentCommand: DOCUMENTED_OPENCLAW_BRIDGE_COMMAND,
|
|
})),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
agentRegistry: {
|
|
resolve: (agentName: string) => (agentName === "openclaw" ? "codex" : agentName),
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultStatus = vi.spyOn(delegate, "getStatus").mockResolvedValue({
|
|
summary: "default",
|
|
});
|
|
const bridgeStatus = vi.spyOn(bridgeSafeDelegate, "getStatus").mockResolvedValue({
|
|
summary: "bridge",
|
|
});
|
|
|
|
const status = await runtime.getStatus({
|
|
handle: {
|
|
sessionKey: "agent:openclaw:acp:test",
|
|
backend: "acpx",
|
|
runtimeSessionName: "agent:openclaw:acp:test",
|
|
},
|
|
});
|
|
|
|
expect(status.summary).toBe("bridge");
|
|
expect(bridgeStatus).toHaveBeenCalledOnce();
|
|
expect(defaultStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("probes through the bridge-safe delegate when probeAgent resolves to openclaw bridge", async () => {
|
|
const baseStore: TestSessionStore = {
|
|
load: vi.fn(async () => undefined),
|
|
save: vi.fn(async () => {}),
|
|
};
|
|
|
|
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
|
mcpServers: [{ name: "tools", command: "mcp-tools" }] as never,
|
|
probeAgent: "openclaw",
|
|
agentRegistry: {
|
|
resolve: (agentName: string) =>
|
|
agentName === "openclaw" ? DOCUMENTED_OPENCLAW_BRIDGE_COMMAND : agentName,
|
|
list: () => ["codex", "openclaw"],
|
|
},
|
|
});
|
|
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
|
|
const bridgeProbe = vi
|
|
.spyOn(bridgeSafeDelegate, "probeAvailability")
|
|
.mockResolvedValue(undefined);
|
|
vi.spyOn(delegate, "isHealthy").mockReturnValue(false);
|
|
vi.spyOn(bridgeSafeDelegate, "isHealthy").mockReturnValue(true);
|
|
|
|
await runtime.probeAvailability();
|
|
|
|
expect(runtime.isHealthy()).toBe(true);
|
|
expect(bridgeProbe).toHaveBeenCalledOnce();
|
|
expect(defaultProbe).not.toHaveBeenCalled();
|
|
});
|
|
});
|