// ACPX tests cover runtime plugin behavior. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { RequestedModelUnsupportedError } from "acpx/runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError, type AcpRuntime, type AcpRuntimeCapabilities, type AcpRuntimeEvent, type AcpRuntimeTurn, } from "../runtime-api.js"; import { OPENCLAW_CODEX_CONFIG_ARG } from "./codex-adapter.js"; import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG, readAcpxProcessLeaseIdentity, } from "./process-lease.js"; import { AcpxRuntime, testing, type AcpSessionStore } from "./runtime.js"; import { ACPX_PROCESS_LEASE_MAX_ENTRIES } from "./state.js"; type TestSessionStore = { load(sessionId: string): Promise | undefined>; save(record: Record): Promise; }; 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 @agentclientprotocol/codex-acp@1.1.2"; 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/@agentclientprotocol/codex-acp/dist/index.js", )}"`; function makeRuntime( baseStore: TestSessionStore, options: Partial[0]> = {}, testOptions?: ConstructorParameters[1], ): { runtime: AcpxRuntime; wrappedStore: TestSessionStore & { markFresh: (sessionKey: string) => void }; delegate: { cancel: AcpRuntime["cancel"]; close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; startTurn: NonNullable; runTurn: AcpRuntime["runTurn"]; getCapabilities: NonNullable; getStatus: NonNullable; setMode: NonNullable; setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; doctor(): Promise<{ ok: boolean; message: string; details?: string[] }>; }; bridgeSafeDelegate: { close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; doctor(): Promise<{ ok: boolean; message: string; details?: string[] }>; }; } { const runtime = new AcpxRuntime( { cwd: "/tmp", sessionStore: baseStore as unknown as AcpSessionStore, 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; runTurn: AcpRuntime["runTurn"]; getCapabilities: NonNullable; getStatus: NonNullable; setMode: NonNullable; setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; doctor(): Promise<{ ok: boolean; message: string; details?: string[] }>; }; } ).delegate, bridgeSafeDelegate: ( runtime as unknown as { bridgeSafeDelegate: { close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; doctor(): Promise<{ ok: boolean; message: string; details?: string[] }>; }; } ).bridgeSafeDelegate, }; } function makeLeaseStore() { const leases = new Map>(); 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) => { leases.set(String(lease.leaseId), lease); }), markState: vi.fn(async (leaseId: string, state: string) => { if (state === "closed" || state === "lost") { leases.delete(leaseId); return; } const lease = leases.get(leaseId); if (lease) { lease.state = state; } }), }, }; } function readFirstEnsureSessionInput(ensure: { mock: { calls: Array> }; }): Parameters[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[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("adds the OpenClaw session key to both managed tools MCP bridges", () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime } = makeRuntime(baseStore, { pluginToolsMcpBridgeEnabled: true, openclawToolsMcpBridgeEnabled: true, mcpServers: [ { name: "openclaw-plugin-tools", command: "node", args: ["dist/mcp/plugin-tools-serve.js"], env: [], }, { name: "openclaw-tools", command: "node", args: ["dist/mcp/openclaw-tools-serve.js"], env: [], }, ], }); const readScopedMcpEnv = (sessionKey: string, serverName: string) => { const delegate = ( runtime as unknown as { resolveManagedToolsDelegateForSession(sessionKey: string): unknown; } ).resolveManagedToolsDelegateForSession(sessionKey) as { options: { mcpServers?: Array<{ env?: Array<{ name: string; value: string }>; name: string; }>; }; }; return delegate.options.mcpServers?.find((server) => server.name === serverName)?.env; }; expect(readScopedMcpEnv("agent:worker:main", "openclaw-plugin-tools")).toContainEqual({ name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY", value: "agent:worker:main", }); expect(readScopedMcpEnv("agent:research:main", "openclaw-tools")).toContainEqual({ name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY", value: "agent:research:main", }); }); it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime } = makeRuntime(baseStore, { openclawToolsMcpBridgeEnabled: true, mcpServers: [ { name: "openclaw-tools", command: "node", args: ["dist/mcp/openclaw-tools-serve.js"], env: [], }, ], }); const exposedRuntime = runtime as unknown as { managedToolsSessionDelegates: Map; resolveManagedToolsDelegateForSession(sessionKey: string): unknown; }; const firstDelegate = exposedRuntime.resolveManagedToolsDelegateForSession("agent:worker:main"); expect(exposedRuntime.managedToolsSessionDelegates.has("agent:worker:main")).toBe(true); await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" }); expect(exposedRuntime.managedToolsSessionDelegates.has("agent:worker:main")).toBe(true); expect(exposedRuntime.resolveManagedToolsDelegateForSession("agent:worker:main")).toBe( firstDelegate, ); }); it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, { openclawToolsMcpBridgeEnabled: true, mcpServers: [ { name: "openclaw-tools", command: "node", args: ["dist/mcp/openclaw-tools-serve.js"], env: [], }, ], }); const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined); const safeProbe = vi .spyOn(bridgeSafeDelegate, "probeAvailability") .mockResolvedValue(undefined); await runtime.probeAvailability(); expect(safeProbe).toHaveBeenCalledTimes(1); expect(defaultProbe).not.toHaveBeenCalled(); }); it("leases generated-wrapper probes before delegate entry and retains absent wrappers", async () => { const events: string[] = []; const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.store.save.mockImplementation(async (lease: Record) => { events.push("lease-saved"); leaseStore.leases.set(String(lease.leaseId), lease); }); 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"], }, }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => { events.push("process-inspected"); return []; }), }, }, ); let launchedCommand = ""; vi.spyOn(delegate, "probeAvailability").mockImplementation(async () => { events.push("probe-entered"); launchedCommand = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); }); await runtime.probeAvailability(); expect(events).toEqual(["lease-saved", "probe-entered", "process-inspected"]); expect(launchedCommand).toContain(OPENCLAW_ACPX_LEASE_ID_ARG); expect(launchedCommand).toContain(`${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`); expect(Array.from(leaseStore.leases.values())).toEqual([ expect.objectContaining({ rootPid: 0, state: "open" }), ]); expect(leaseStore.store.markState).not.toHaveBeenCalledWith(expect.any(String), "lost"); }); it("reaps a fulfilled probe wrapper that exact live evidence still finds", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); let launchedCommand = ""; const killed: Array<{ pid: number; signal: NodeJS.Signals }> = []; 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"], }, }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => [ { pid: 710, ppid: 1, command: launchedCommand }, { pid: 711, ppid: 710, command: "node adapter-child.js" }, ]), killProcess: vi.fn((pid, signal) => { killed.push({ pid, signal }); }), sleep: vi.fn(async () => {}), }, }, ); vi.spyOn(delegate, "probeAvailability").mockImplementation(async () => { launchedCommand = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); }); await runtime.probeAvailability(); expect(killed.slice(0, 2)).toEqual([ { pid: 711, signal: "SIGTERM" }, { pid: 710, signal: "SIGTERM" }, ]); expect(Array.from(leaseStore.leases.values())).toEqual([ expect.objectContaining({ rootPid: 0, state: "open" }), ]); }); it("retains a fulfilled probe lease when live evidence is unavailable", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), 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"], }, }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => { throw new Error("process evidence unavailable"); }), }, }, ); vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined); await runtime.probeAvailability(); expect(Array.from(leaseStore.leases.values())).toEqual([ expect.objectContaining({ rootPid: 0, state: "open" }), ]); }); it("coalesces repeated probe uncertainty before it can evict a live lease", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-live", { leaseId: "lease-live", gatewayInstanceId: "gateway-test", sessionKey: "agent:codex:acp:live", wrapperRoot: "/tmp/openclaw/acpx", wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs", rootPid: 700, commandHash: "hash-live", startedAt: 1, state: "open", }); leaseStore.store.save.mockImplementation(async (lease: Record) => { const leaseId = String(lease.leaseId); leaseStore.leases.delete(leaseId); leaseStore.leases.set(leaseId, lease); if (leaseStore.leases.size > ACPX_PROCESS_LEASE_MAX_ENTRIES) { const oldestLeaseId = leaseStore.leases.keys().next().value; if (oldestLeaseId) { leaseStore.leases.delete(oldestLeaseId); } } }); 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"], }, }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => []), }, }, ); const probeLeaseIds = new Set(); vi.spyOn(delegate, "probeAvailability").mockImplementation(async () => { const command = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); const identity = readAcpxProcessLeaseIdentity(command); expect(identity).toBeDefined(); probeLeaseIds.add(String(identity?.leaseId)); }); for (let index = 0; index <= ACPX_PROCESS_LEASE_MAX_ENTRIES; index += 1) { await runtime.probeAvailability(); } const { runtime: updatedRuntime, delegate: updatedDelegate } = makeRuntime( baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", agentRegistry: { resolve: (agentName: string) => agentName === "codex" ? `${CODEX_ACP_WRAPPER_COMMAND} --updated` : agentName, list: () => ["codex"], }, }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => []), }, }, ); vi.spyOn(updatedDelegate, "probeAvailability").mockImplementation(async () => { const command = ( updatedRuntime as unknown as { scopedAgentRegistry: { resolve(agent: string): string }; } ).scopedAgentRegistry.resolve("codex"); const identity = readAcpxProcessLeaseIdentity(command); expect(identity).toBeDefined(); probeLeaseIds.add(String(identity?.leaseId)); }); await updatedRuntime.probeAvailability(); expect(leaseStore.leases.has("lease-live")).toBe(true); expect(leaseStore.leases.size).toBe(2); expect(probeLeaseIds.size).toBe(1); }); it("leases generated-wrapper doctor probes and keeps uncertain failures open", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), 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"], }, }); vi.spyOn(delegate, "doctor").mockImplementation(async () => { const command = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); expect(command).toContain(OPENCLAW_ACPX_LEASE_ID_ARG); throw new Error("probe launch state unknown"); }); await expect(runtime.doctor()).rejects.toThrow("probe launch state unknown"); expect(Array.from(leaseStore.leases.values())).toEqual([ expect.objectContaining({ gatewayInstanceId: "gateway-test", rootPid: 0, sessionKey: "openclaw:acpx:probe", state: "open", }), ]); expect(leaseStore.store.markState).not.toHaveBeenCalledWith(expect.any(String), "lost"); }); 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/gpt-5.4", }); expect(readFirstEnsureSessionInput(ensure)).toEqual({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model: "gpt-5.4", sessionOptions: { model: "gpt-5.4" }, }); }); it.each([ { name: "strips the OpenClaw Anthropic provider prefix for Claude ACP startup", model: "anthropic/claude-sonnet-4-6", expectedModel: "claude-sonnet-4-6", }, { name: "preserves custom Claude ACP startup models", model: "custom-model", expectedModel: "custom-model", }, ])("$name", async ({ model, expectedModel }) => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime, delegate } = makeRuntime(baseStore, { agentRegistry: { resolve: (agentName: string) => agentName === "claude" ? "npx @agentclientprotocol/claude-agent-acp" : agentName, list: () => ["claude", "openclaw"], }, }); const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ sessionKey: "agent:claude:acp:test", backend: "acpx", runtimeSessionName: "claude", }); await runtime.ensureSession({ sessionKey: "agent:claude:acp:test", agent: "claude", mode: "persistent", model, }); expect(readFirstEnsureSessionInput(ensure)).toEqual({ sessionKey: "agent:claude:acp:test", agent: "claude", mode: "persistent", model: expectedModel, sessionOptions: { model: expectedModel }, }); }); 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.each([ { name: "adds the redacted Codex wrapper stderr tail to session initialization failures", stderr: "noise\nUnhandled error during session/new: deployment missing token=[REDACTED] sk-testsecret1234567890\n", expectedFragment: "deployment missing", forbiddenFragment: "sk-testsecret1234567890", }, { name: "keeps the 6,000-unit Codex wrapper stderr tail UTF-16 safe", stderr: `🚀${"a".repeat(5_999)}`, expectedFragment: `Internal error: ${"a".repeat(5_999)}`, forbiddenFragment: "\ude80", }, ])("$name", async ({ stderr, expectedFragment, forbiddenFragment }) => { 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`), stderr, "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: unknown) => ({ 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(expectedFragment), }); const error = outcome.error; expect(error).toBeInstanceOf(AcpRuntimeError); if (!(error instanceof AcpRuntimeError)) { throw new Error("expected AcpRuntimeError"); } expect(error.message).not.toContain(forbiddenFragment); }); 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 = { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined as never }), }), }; yield* emptyAsyncIterable; throw new Error("Internal error"); }); await expect(async () => { for await (const ignoredEventValue 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", })) { void ignoredEventValue; // 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 ignoredEventValue 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", })) { void ignoredEventValue; // 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 ignoredEventValue of turn.events) { void ignoredEventValue; // no-op } await turn.result; expect(startTurn).toHaveBeenCalledWith( expect.objectContaining({ timeoutMs: 0, }), ); }); it("passes model startup through sessionOptions 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/gpt-5.5", }); expect(readFirstEnsureSessionInput(ensure)).toEqual({ sessionKey: "agent:main:acp:test", agent: "main", mode: "persistent", model: "openai/gpt-5.5", sessionOptions: { model: "openai/gpt-5.5" }, }); }); it("retries without a model when ACPX reports missing model capability", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime, delegate } = makeRuntime(baseStore, { agentRegistry: { resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName), list: () => ["opencode"], }, }); const ensure = vi .spyOn(delegate, "ensureSession") .mockRejectedValueOnce( new RequestedModelUnsupportedError( "Cannot apply --model: the ACP agent did not advertise model support", "missing-capability", ), ) .mockResolvedValueOnce({ sessionKey: "agent:opencode:acp:test", backend: "acpx", runtimeSessionName: "opencode", }); await runtime.ensureSession({ sessionKey: "agent:opencode:acp:test", agent: "opencode", mode: "persistent", model: "openrouter/owl-alpha", }); expect(ensure).toHaveBeenCalledTimes(2); expect(readFirstEnsureSessionInput(ensure)).toMatchObject({ model: "openrouter/owl-alpha", sessionOptions: { model: "openrouter/owl-alpha" }, }); const [, secondCall] = ensure.mock.calls; expect(secondCall?.[0]).not.toHaveProperty("sessionOptions"); expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined(); }); it("does not retry when ACPX rejects an explicitly unsupported model id", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime, delegate } = makeRuntime(baseStore, { agentRegistry: { resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName), list: () => ["opencode"], }, }); const ensure = vi .spyOn(delegate, "ensureSession") .mockRejectedValueOnce( new RequestedModelUnsupportedError( "Cannot apply --model: the ACP agent did not advertise that model", "unadvertised-model", ), ); await expect( runtime.ensureSession({ sessionKey: "agent:opencode:acp:test", agent: "opencode", mode: "persistent", model: "unknown/model", }), ).rejects.toThrow("did not advertise that model"); expect(ensure).toHaveBeenCalledTimes(1); }); it("does not retry an unrelated error with similar wording", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime, delegate } = makeRuntime(baseStore); const ensure = vi .spyOn(delegate, "ensureSession") .mockRejectedValueOnce(new Error("the ACP agent did not advertise model support")); await expect( runtime.ensureSession({ sessionKey: "agent:main:acp:test", agent: "main", mode: "persistent", model: "openrouter/owl-alpha", }), ).rejects.toThrow("did not advertise model support"); expect(ensure).toHaveBeenCalledTimes(1); }); 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 @agentclientprotocol/codex-acp@1.1.2 ${OPENCLAW_CODEX_CONFIG_ARG} '{"model":"gpt-5.4","model_reasoning_effort":"medium"}'`, ); expect(testing.isCodexAcpCommand("openclaw acp")).toBe(false); expect(testing.normalizeAgentCommand(["node", "/tmp/codex acp/index.js", "--label", ""])).toBe( "node '/tmp/codex acp/index.js' --label ''", ); }); 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/gpt-5.5", }); expect(readFirstEnsureSessionInput(ensure)).toEqual({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model: "gpt-5.5", sessionOptions: { model: "gpt-5.5" }, }); }); it("passes gpt-5.6-sol and medium as separate Codex ACP startup controls", 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/gpt-5.6-sol", thinking: "medium", }); const ensureInput = readFirstEnsureSessionInput(ensure); expect(ensureInput).toEqual({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model: "gpt-5.6-sol", thinking: "medium", sessionOptions: { model: "gpt-5.6-sol" }, }); expect(JSON.stringify(ensureInput)).not.toContain("gpt-5.6-sol/medium"); expect( testing.appendCodexAcpConfigOverrides(CODEX_ACP_WRAPPER_COMMAND, { model: "gpt-5.6-sol", reasoningEffort: "medium", }), ).not.toContain("gpt-5.6-sol/medium"); }); it("starts Codex ACP without injecting a leaked non-openai default model", 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: "google/gemini-3.1-flash-lite", }); 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("sessionOptions"); }); it("reports a dropped leaked non-openai default on the returned handle", 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"], }, }); vi.spyOn(delegate, "ensureSession").mockResolvedValue({ sessionKey: "agent:codex:acp:test", backend: "acpx", runtimeSessionName: "codex", }); const handle = await runtime.ensureSession({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model: "google/gemini-3.1-flash-lite", }); expect(handle.appliedModel).toEqual({ kind: "dropped" }); }); it("reports a supported codex model as applied on the returned handle", 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"], }, }); vi.spyOn(delegate, "ensureSession").mockResolvedValue({ sessionKey: "agent:codex:acp:test", backend: "acpx", runtimeSessionName: "codex", }); const handle = await runtime.ensureSession({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model: "openai/gpt-5.5", }); expect(handle.appliedModel).toEqual({ kind: "applied", model: "openai/gpt-5.5" }); }); it("applies explicit Codex ACP thinking while dropping a leaked non-openai default model", 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: "google/gemini-3.1-flash-lite", thinking: "low", }); const ensureInput = readFirstEnsureSessionInput(ensure); expect(ensureInput).not.toHaveProperty("model"); expect(ensureInput).not.toHaveProperty("sessionOptions"); expect(ensureInput).toMatchObject({ thinking: "low" }); }); it("drops a leaked malformed Codex ACP default at spawn instead of failing the session", 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: "gpt-5.4/ultra", }); const ensureInput = readFirstEnsureSessionInput(ensure); expect(ensureInput).not.toHaveProperty("model"); expect(ensureInput).not.toHaveProperty("sessionOptions"); }); it.each(["google/gemini-3.1-flash-lite", "gpt-5.4/ultra"])( "fails closed on an explicit unsupported Codex ACP spawn model %s without calling the delegate", async (model) => { 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 expect( runtime.ensureSession({ sessionKey: "agent:codex:acp:test", agent: "codex", mode: "persistent", model, modelExplicit: true, }), ).rejects.toMatchObject({ code: "ACP_INVALID_RUNTIME_OPTION" }); expect(ensure).not.toHaveBeenCalled(); }, ); it("passes an explicit supported Codex ACP spawn model through without leaking the provenance flag", 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/gpt-5.5", modelExplicit: true, }); const ensureInput = readFirstEnsureSessionInput(ensure); expect(ensureInput).not.toHaveProperty("modelExplicit"); expect(ensureInput).toMatchObject({ model: "gpt-5.5", sessionOptions: { model: "gpt-5.5" }, }); }); it.each([ { name: "normalizes OpenClaw-qualified Codex ACP model controls", value: "openai/gpt-5.4", }, { name: "passes bare Codex ACP model controls through", value: "gpt-5.4" }, ])("$name", async ({ value }) => { 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>[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, }); expect(setConfigOption).toHaveBeenCalledOnce(); expect(setConfigOption).toHaveBeenCalledWith({ handle, key: "model", value: "gpt-5.4", }); }); it.each([ "google/gemini-3.1-flash-lite", "gpt-5.4/ultra", "openai/foo/bar", "openai/", "openai//high", ])("fails closed on Codex ACP model config control %s without re-injecting it", async (value) => { 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>[0]["handle"] = { sessionKey: "agent:codex:acp:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:test", acpxRecordId: "agent:codex:acp:test", }; await expect(runtime.setConfigOption({ handle, key: "model", value })).rejects.toMatchObject({ code: "ACP_INVALID_RUNTIME_OPTION", }); expect(setConfigOption).not.toHaveBeenCalled(); }); 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>[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/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("forwards getCapabilities input handles to the ACPX delegate", 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 delegateCapabilities: AcpRuntimeCapabilities = { controls: ["session/set_config_option"], configOptionKeys: ["reasoning_effort", "model"], }; const getCapabilities = vi .spyOn(delegate, "getCapabilities") .mockResolvedValue(delegateCapabilities); const handle: Parameters>[0]["handle"] = { sessionKey: "agent:codex:acp:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:test", acpxRecordId: "agent:codex:acp:test", }; const input = { handle }; const result = await runtime.getCapabilities?.(input); expect(getCapabilities).toHaveBeenCalledWith(input); expect(result).toBe(delegateCapabilities); }); it.each([ { name: "normalizes Codex ACP thinking=minimal to reasoning effort", key: "thinking", value: "minimal", expected: "low", }, { name: "normalizes Codex ACP reasoning_effort=x-high", key: "reasoning_effort", value: "x-high", expected: "xhigh", }, { name: "rejects unsupported Codex ACP thinking controls", key: "thinking", value: "superhigh", }, ])("$name", async ({ key, value, expected }) => { 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>[0]["handle"] = { sessionKey: "agent:codex:acp:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:test", acpxRecordId: "agent:codex:acp:test", }; const update = runtime.setConfigOption({ handle, key, value, }); if (!expected) { await expect(update).rejects.toMatchObject({ code: "ACP_INVALID_RUNTIME_OPTION" }); expect(setConfigOption).not.toHaveBeenCalled(); return; } await update; expect(setConfigOption).toHaveBeenCalledWith({ handle, key: "reasoning_effort", value: expected, }); }); it("forwards unsupported thinking config rejection for non-Codex ACP sessions", async () => { const unsupportedThinkingError = new AcpRuntimeError( "ACP_BACKEND_UNSUPPORTED_CONTROL", "unsupported thinking", ); const baseStore: TestSessionStore = { load: vi.fn(async () => ({ acpxRecordId: "agent:gemini:acp:test", agentCommand: "gemini --experimental-acp", })), save: vi.fn(async () => {}), }; const { runtime, delegate } = makeRuntime(baseStore); const setConfigOption = vi .spyOn(delegate, "setConfigOption") .mockRejectedValue(unsupportedThinkingError); const handle: Parameters>[0]["handle"] = { sessionKey: "agent:gemini:acp:test", backend: "acpx", runtimeSessionName: "agent:gemini:acp:test", acpxRecordId: "agent:gemini:acp:test", }; await expect( runtime.setConfigOption({ handle, key: "thinking", value: "high", }), ).rejects.toBe(unsupportedThinkingError); expect(setConfigOption).toHaveBeenCalledWith({ handle, key: "thinking", value: "high", }); }); 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>[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>[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("normalizes model 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>[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: "anthropic/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 @agentclientprotocol/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("releases managed OpenClaw tools MCP delegates after close", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => {}), }; const { runtime } = makeRuntime(baseStore, { openclawToolsMcpBridgeEnabled: true, mcpServers: [ { name: "openclaw-tools", command: "node", args: ["dist/mcp/openclaw-tools-serve.js"], env: [], }, ], }); const exposedRuntime = runtime as unknown as { managedToolsSessionDelegates: Map; resolveManagedToolsDelegateForSession(sessionKey: string): { close: AcpRuntime["close"]; }; }; const scopedDelegate = exposedRuntime.resolveManagedToolsDelegateForSession("agent:codex:main"); const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined); await runtime.close({ handle: { sessionKey: "agent:codex:main", backend: "acpx", runtimeSessionName: "agent:codex:main", }, reason: "closed", }); expect(close).toHaveBeenCalledOnce(); expect(exposedRuntime.managedToolsSessionDelegates.has("agent:codex:main")).toBe(false); }); 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/@agentclientprotocol/codex-acp/dist/index.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("persists ACPX process lease identity for later wrapper reconnects", async () => { const savedRecords: Record[] = []; const launchCommands: string[] = []; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecords.at(-1)), 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_ARG); expect(launchCommands[0]).toContain(OPENCLAW_GATEWAY_INSTANCE_ID_ARG); expect(savedRecords[0]?.agentCommand).toBe(launchCommands[0]); 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 leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-existing ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: leasedCommand, cwd: "/tmp", closed: false, pid: 777, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-existing", { leaseId: "lease-existing", 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 { 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([leasedCommand]); expect(leaseStore.store.save).not.toHaveBeenCalled(); }); it("recreates a missing sidecar with the persisted lease identity", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-missing ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; let savedRecord: Record = { name: "agent:codex:acp:binding:test", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: leasedCommand, cwd: "/tmp", closed: false, }; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"); await wrappedStore.save({ ...savedRecord, 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(savedRecord.agentCommand).toBe(leasedCommand); expect(leaseStore.leases.get("lease-missing")).toMatchObject({ leaseId: "lease-missing", rootPid: 777, }); expect(leaseStore.leases.size).toBe(1); }); it("does not reuse commands leased by another gateway instance", async () => { const foreignCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-foreign ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-foreign`; let savedRecord: Record = { name: "agent:codex:acp:binding:test", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: foreignCommand, cwd: "/tmp", closed: false, pid: 777, }; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"], }, }); const resolvedCommands: string[] = []; vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => { const command = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); resolvedCommands.push(command); await wrappedStore.save({ name: input.sessionKey, agentCommand: command, cwd: "/tmp", pid: 888, }); return { sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: input.sessionKey, }; }); await runtime.ensureSession({ sessionKey: "agent:codex:acp:binding:test", agent: "codex", mode: "persistent", }); expect(resolvedCommands[0]).not.toBe(foreignCommand); expect(resolvedCommands[0]).toContain(`${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`); expect(savedRecord.pid).toBe(888); expect(leaseStore.leases.size).toBe(1); }); it("rejects reconnect operations for commands leased by another gateway", async () => { const foreignCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-foreign-operation ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-foreign`; const handle = { sessionKey: "agent:codex:acp:binding:test", backend: "acpx" as const, runtimeSessionName: "agent:codex:acp:binding:test", }; const expectedError = { code: "ACP_TURN_FAILED", message: "ACPX process lease lease-foreign-operation belongs to another gateway", }; const createRuntime = () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: handle.sessionKey, agentCommand: foreignCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawToolsMcpBridgeEnabled: true, openclawWrapperRoot: "/tmp/openclaw/acpx", mcpServers: [ { name: "openclaw-tools", command: "node", args: ["dist/mcp/openclaw-tools-serve.js"], env: [], }, ], }); const managedToolsSessionDelegates = ( runtime as unknown as { managedToolsSessionDelegates: Map; } ).managedToolsSessionDelegates; return { runtime, leaseStore, managedToolsSessionDelegates }; }; const expectRejectedWithoutDelegate = async ( operation: (runtime: AcpxRuntime) => Promise, ) => { const { runtime, leaseStore, managedToolsSessionDelegates } = createRuntime(); await expect(operation(runtime)).rejects.toMatchObject(expectedError); expect(managedToolsSessionDelegates.has(handle.sessionKey)).toBe(false); expect(managedToolsSessionDelegates.size).toBe(0); expect(leaseStore.leases.size).toBe(0); }; await expectRejectedWithoutDelegate((runtime) => runtime.setConfigOption({ handle, key: "thinking", value: "minimal" }), ); await expectRejectedWithoutDelegate((runtime) => runtime.setMode({ handle, mode: "plan" })); await expectRejectedWithoutDelegate((runtime) => runtime.close({ handle, reason: "done" })); const { runtime, leaseStore, managedToolsSessionDelegates } = createRuntime(); const turn = runtime.startTurn({ handle, text: "Reply exactly OK", mode: "prompt", requestId: "foreign-gateway", }); const outcomes = await Promise.allSettled([turn.result, turn.cancel(), turn.closeStream()]); for (const outcome of outcomes) { expect(outcome.status).toBe("rejected"); if (outcome.status === "rejected") { expect(outcome.reason).toMatchObject(expectedError); } } expect(managedToolsSessionDelegates.has(handle.sessionKey)).toBe(false); expect(managedToolsSessionDelegates.size).toBe(0); expect(leaseStore.leases.size).toBe(0); }); it("serializes concurrent persistent ensures for one session", async () => { let savedRecord: Record | undefined; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"], }, }); let releaseFirst!: () => void; const firstBlocked = new Promise((resolve) => { releaseFirst = resolve; }); let entered = 0; let active = 0; let maxActive = 0; const resolvedCommands: string[] = []; const ensure = vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => { entered += 1; active += 1; maxActive = Math.max(maxActive, active); const command = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); resolvedCommands.push(command); if (entered === 1) { await wrappedStore.save({ name: input.sessionKey, acpSessionId: "session-1", agentCommand: command, cwd: "/tmp", pid: 777, }); await firstBlocked; } else if (savedRecord) { await wrappedStore.save(savedRecord); } active -= 1; return { sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: input.sessionKey, }; }); const ensureInput = { sessionKey: "agent:codex:acp:binding:test", agent: "codex", mode: "persistent" as const, }; const first = runtime.ensureSession(ensureInput); while (ensure.mock.calls.length === 0) { await Promise.resolve(); } const second = runtime.ensureSession(ensureInput); await Promise.resolve(); expect(ensure).toHaveBeenCalledTimes(1); releaseFirst(); await Promise.all([first, second]); expect(maxActive).toBe(1); expect(resolvedCommands[1]).toBe(resolvedCommands[0]); expect(leaseStore.leases.size).toBe(1); }); it("adopts legacy persistent commands before their next reconnect", async () => { let savedRecord: Record = { name: "agent:codex:acp:binding:test", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: CODEX_ACP_WRAPPER_COMMAND, cwd: "/tmp", closed: false, pid: 777, }; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"], }, }); const resolvedCommands: string[] = []; vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => { resolvedCommands.push( ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"), ); await wrappedStore.save(savedRecord); 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(savedRecord.agentCommand).toContain(OPENCLAW_ACPX_LEASE_ID_ARG); expect(savedRecord.agentCommand).toContain(OPENCLAW_GATEWAY_INSTANCE_ID_ARG); expect(savedRecord.pid).toBeUndefined(); expect(leaseStore.leases.size).toBe(0); await wrappedStore.save({ ...savedRecord, pid: 888 }); const [lease] = Array.from(leaseStore.leases.values()); expect(lease?.leaseId).toBe(savedRecord.openclawLeaseId); expect(lease?.rootPid).toBe(888); }); it("keeps pending process leases when a fresh launch fails after spawn may have occurred", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), 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"], }, }); vi.spyOn(delegate, "ensureSession").mockRejectedValue(new Error("launch failed")); await expect( runtime.ensureSession({ sessionKey: "agent:codex:acp:binding:test", agent: "codex", mode: "persistent", }), ).rejects.toThrow("launch failed"); expect(Array.from(leaseStore.leases.values())).toEqual([ expect.objectContaining({ rootPid: 0, state: "open" }), ]); expect(leaseStore.store.markState).not.toHaveBeenCalledWith(expect.any(String), "lost"); }); it("preserves promoted process leases when session setup later fails", async () => { let savedRecord: Record | undefined; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"); await wrappedStore.save({ name: input.sessionKey, agentCommand: command, cwd: "/tmp", pid: 777, }); throw new Error("setup failed after spawn"); }); await expect( runtime.ensureSession({ sessionKey: "agent:codex:acp:binding:test", agent: "codex", mode: "persistent", }), ).rejects.toThrow("setup failed after spawn"); const [lease] = Array.from(leaseStore.leases.values()); expect(lease?.rootPid).toBe(777); expect(leaseStore.leases.size).toBe(1); }); it("restores a pending process lease before runTurn reconnects", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-turn-reconnect ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { expect(leaseStore.leases.get("lease-turn-reconnect")).toMatchObject({ rootPid: 0, sessionKey: "agent:codex:acp:binding:test", }); yield { type: "status", text: "reconnecting" }; }); for await (const event of runtime.runTurn({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, text: "Reply exactly OK", mode: "prompt", requestId: "turn-reconnect", })) { void event; } expect(leaseStore.leases.size).toBe(0); }); it("restores a missing sidecar from the persisted lease PID", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-live-reconnect ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, pid: 777, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { expect(leaseStore.leases.get("lease-live-reconnect")).toMatchObject({ rootPid: 777, sessionKey: "agent:codex:acp:binding:test", }); yield { type: "status", text: "connected" }; }); for await (const event of runtime.runTurn({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, text: "Reply exactly OK", mode: "prompt", requestId: "turn-live-reconnect", })) { void event; } expect(leaseStore.leases.get("lease-live-reconnect")).toMatchObject({ rootPid: 777, state: "open", }); }); it("retires runTurn pending leases when handle resolution fails", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-turn-resolution ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; let loads = 0; const baseStore: TestSessionStore = { load: vi.fn(async () => { loads += 1; if (loads >= 2) { throw new Error("session load failed"); } return { name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, }; }), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); await expect(async () => { for await (const event of runtime.runTurn({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, text: "Reply exactly OK", mode: "prompt", requestId: "turn-resolution", })) { void event; } }).rejects.toThrow("session load failed"); expect(leaseStore.leases.size).toBe(0); }); it("restores a pending process lease before startTurn reconnects", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-start-reconnect ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "startTurn").mockImplementation((input) => { expect(leaseStore.leases.get("lease-start-reconnect")).toMatchObject({ rootPid: 0, sessionKey: "agent:codex:acp:binding:test", }); return { requestId: input.requestId, events: (async function* () {})(), result: Promise.resolve({ status: "completed" }), cancel: vi.fn(async () => {}), closeStream: vi.fn(async () => {}), }; }); const turn = runtime.startTurn({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, text: "Reply exactly OK", mode: "prompt", requestId: "start-reconnect", }); await expect(turn.result).resolves.toEqual({ status: "completed" }); expect(leaseStore.leases.size).toBe(0); }); it("loads one wrapper snapshot per handle operation before mutation", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-control-reconnect ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); const expectPendingLease = () => { expect(leaseStore.leases.get("lease-control-reconnect")).toMatchObject({ rootPid: 0, sessionKey: "agent:codex:acp:binding:test", }); }; vi.spyOn(delegate, "startTurn").mockImplementation((input) => { expectPendingLease(); return { requestId: input.requestId, events: (async function* () {})(), result: Promise.resolve({ status: "completed" }), cancel: vi.fn(async () => {}), closeStream: vi.fn(async () => {}), }; }); vi.spyOn(delegate, "setMode").mockImplementation(async () => expectPendingLease()); const setConfigOption = vi .spyOn(delegate, "setConfigOption") .mockImplementation(async () => expectPendingLease()); vi.spyOn(delegate, "close").mockImplementation(async () => expectPendingLease()); const handle = { sessionKey: "agent:codex:acp:binding:test", backend: "acpx" as const, runtimeSessionName: "agent:codex:acp:binding:test", }; const operations = [ async () => await runtime.startTurn({ handle, text: "OK", mode: "prompt", requestId: "1" }).result, async () => await runtime.setConfigOption({ handle, key: "thinking", value: "minimal" }), async () => await runtime.setMode({ handle, mode: "plan" }), async () => await runtime.close({ handle, reason: "done" }), ]; for (const operation of operations) { vi.mocked(baseStore["load"]).mockClear(); await operation(); expect(baseStore["load"]).toHaveBeenCalledOnce(); expect(leaseStore.leases.size).toBe(0); } expect(setConfigOption).toHaveBeenCalledWith({ handle, key: "reasoning_effort", value: "low", }); }); it("preserves a promoted PID when the session record save fails", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-partial-save ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const savedRecord: Record = { name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, pid: 777, }; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async () => { throw new Error("session save failed"); }), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-partial-save", { leaseId: "lease-partial-save", 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 { runtime, delegate, wrappedStore } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "setMode").mockImplementation(async () => { await wrappedStore.save({ ...savedRecord, pid: 888 }); }); await expect( runtime.setMode({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, mode: "plan", }), ).rejects.toThrow("session save failed"); expect(leaseStore.leases.get("lease-partial-save")).toMatchObject({ rootPid: 888, state: "open", }); }); it("keeps a shared pending lease until the last concurrent operation finishes", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-concurrent-operations ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); let markTurnStarted!: () => void; const turnStarted = new Promise((resolve) => { markTurnStarted = resolve; }); let releaseTurn!: () => void; const turnBlocked = new Promise((resolve) => { releaseTurn = resolve; }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { markTurnStarted(); await turnBlocked; yield { type: "status", text: "completed" }; }); vi.spyOn(delegate, "setMode").mockResolvedValue(undefined); const handle = { sessionKey: "agent:codex:acp:binding:test", backend: "acpx" as const, runtimeSessionName: "agent:codex:acp:binding:test", }; const turn = (async () => { for await (const event of runtime.runTurn({ handle, text: "Reply exactly OK", mode: "prompt", requestId: "turn-concurrent-operations", })) { void event; } })(); await turnStarted; await runtime.setMode({ handle, mode: "plan" }); expect(leaseStore.leases.get("lease-concurrent-operations")).toMatchObject({ rootPid: 0, }); releaseTurn(); await turn; expect(leaseStore.leases.size).toBe(0); }); it("retires an old lease after the session record switches identity", async () => { const oldCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-old-operation ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const newCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-new-session ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; let savedRecord: Record = { name: "agent:codex:acp:binding:test", agentCommand: oldCommand, pid: 777, }; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = record; }), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-old-operation", { leaseId: "lease-old-operation", 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 { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); let markTurnStarted!: () => void; const turnStarted = new Promise((resolve) => { markTurnStarted = resolve; }); let releaseTurn!: () => void; const turnBlocked = new Promise((resolve) => { releaseTurn = resolve; }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { markTurnStarted(); await turnBlocked; yield { type: "status", text: "completed" }; }); const handle = { sessionKey: "agent:codex:acp:binding:test", backend: "acpx" as const, runtimeSessionName: "agent:codex:acp:binding:test", }; const turn = (async () => { for await (const event of runtime.runTurn({ handle, text: "Reply exactly OK", mode: "prompt", requestId: "turn-old-operation", })) { void event; } })(); await turnStarted; savedRecord = { name: handle.sessionKey, agentCommand: newCommand, pid: 888, }; releaseTurn(); await turn; expect(leaseStore.leases.has("lease-old-operation")).toBe(false); expect(leaseStore.store.markState).toHaveBeenCalledWith("lease-old-operation", "lost"); }); it("keeps launch ownership while a concurrent reconnect operation is active", async () => { let savedRecord: Record | undefined; const baseStore: TestSessionStore = { load: vi.fn(async () => savedRecord), save: vi.fn(async (record) => { savedRecord = 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"], }, }); let markLaunchPersisted!: () => void; const launchPersisted = new Promise((resolve) => { markLaunchPersisted = resolve; }); let failLaunch!: () => void; const launchBlocked = new Promise((resolve) => { failLaunch = resolve; }); vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => { const command = ( runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } } ).scopedAgentRegistry.resolve("codex"); await wrappedStore.save({ name: input.sessionKey, agentCommand: command, cwd: "/tmp", }); markLaunchPersisted(); await launchBlocked; throw new Error("launch failed"); }); let markControlStarted!: () => void; const controlStarted = new Promise((resolve) => { markControlStarted = resolve; }); let releaseControl!: () => void; const controlBlocked = new Promise((resolve) => { releaseControl = resolve; }); vi.spyOn(delegate, "setMode").mockImplementation(async () => { markControlStarted(); await controlBlocked; }); const sessionKey = "agent:codex:acp:binding:test"; const handle = { sessionKey, backend: "acpx" as const, runtimeSessionName: sessionKey, }; const launch = runtime.ensureSession({ sessionKey, agent: "codex", mode: "persistent", }); await launchPersisted; const control = runtime.setMode({ handle, mode: "plan" }); await controlStarted; failLaunch(); await expect(launch).rejects.toThrow("launch failed"); const leaseId = String(savedRecord?.openclawLeaseId); expect(leaseStore.leases.get(leaseId)).toMatchObject({ leaseId, rootPid: 0, }); releaseControl(); await control; expect(leaseStore.leases.get(leaseId)).toMatchObject({ rootPid: 0, state: "open" }); }); it("serializes last-owner retirement with the next lease acquisition", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-retirement-race ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); let leaseLoads = 0; let markRetirementStarted!: () => void; const retirementStarted = new Promise((resolve) => { markRetirementStarted = resolve; }); let releaseRetirement!: () => void; const retirementBlocked = new Promise((resolve) => { releaseRetirement = resolve; }); leaseStore.store.load.mockImplementation(async (leaseId: string) => { leaseLoads += 1; if (leaseLoads === 2) { markRetirementStarted(); await retirementBlocked; } return leaseStore.leases.get(leaseId) as never; }); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { yield { type: "status", text: "completed" }; }); const setMode = vi.spyOn(delegate, "setMode").mockResolvedValue(undefined); const handle = { sessionKey: "agent:codex:acp:binding:test", backend: "acpx" as const, runtimeSessionName: "agent:codex:acp:binding:test", }; const turn = (async () => { for await (const event of runtime.runTurn({ handle, text: "Reply exactly OK", mode: "prompt", requestId: "turn-retirement-race", })) { void event; } })(); await retirementStarted; const control = runtime.setMode({ handle, mode: "plan" }); await Promise.resolve(); expect(setMode).not.toHaveBeenCalled(); releaseRetirement(); await turn; await control; expect(setMode).toHaveBeenCalledTimes(1); expect(leaseStore.store.save).toHaveBeenCalledTimes(2); expect(leaseStore.leases.size).toBe(0); }); it("rechecks a reusable sidecar after the prior owner retires it", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-reusable-race ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const sessionKey = "agent:codex:acp:binding:test"; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: sessionKey, acpSessionId: "session-1", agentCommand: leasedCommand, cwd: "/tmp", })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-reusable-race", { leaseId: "lease-reusable-race", gatewayInstanceId: "gateway-test", sessionKey, wrapperRoot: "/tmp/openclaw/acpx", wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs", rootPid: 0, commandHash: "hash", startedAt: 1, state: "open", }); let leaseLoads = 0; let markRetirementStarted!: () => void; const retirementStarted = new Promise((resolve) => { markRetirementStarted = resolve; }); let releaseRetirement!: () => void; const retirementBlocked = new Promise((resolve) => { releaseRetirement = resolve; }); leaseStore.store.load.mockImplementation(async (leaseId: string) => { leaseLoads += 1; if (leaseLoads === 2) { markRetirementStarted(); await retirementBlocked; } return leaseStore.leases.get(leaseId) as never; }); 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"], }, }); vi.spyOn(delegate, "runTurn").mockImplementation(async function* () { yield { type: "status", text: "completed" }; }); vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => { expect(leaseStore.leases.get("lease-reusable-race")).toMatchObject({ rootPid: 0, sessionKey, }); return { sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: input.sessionKey, }; }); const handle = { sessionKey, backend: "acpx" as const, runtimeSessionName: sessionKey, }; const turn = (async () => { for await (const event of runtime.runTurn({ handle, text: "Reply exactly OK", mode: "prompt", requestId: "turn-reusable-race", })) { void event; } })(); await retirementStarted; const ensure = runtime.ensureSession({ sessionKey, agent: "codex", mode: "persistent", }); releaseRetirement(); await turn; await ensure; expect(leaseStore.store.save).toHaveBeenCalledTimes(1); expect(leaseStore.leases.size).toBe(0); }); it("keeps close pending leases when cleanup fails", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-close-failure ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); const { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "close").mockResolvedValue(undefined); vi.spyOn( runtime as unknown as { cleanupProcessTreeForRecord: () => Promise; }, "cleanupProcessTreeForRecord", ).mockRejectedValue(new Error("cleanup failed")); await expect( runtime.close({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, reason: "user-close", }), ).rejects.toThrow("cleanup failed"); expect(leaseStore.leases.get("lease-close-failure")).toMatchObject({ rootPid: 0, state: "open", }); }); it("preserves PID-bearing close leases when cleanup fails", async () => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-close-live ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, pid: 777, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-close-live", { leaseId: "lease-close-live", 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 { runtime, delegate } = makeRuntime(baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }); vi.spyOn(delegate, "close").mockResolvedValue(undefined); vi.spyOn( runtime as unknown as { cleanupProcessTreeForRecord: () => Promise; }, "cleanupProcessTreeForRecord", ).mockRejectedValue(new Error("cleanup failed")); await expect( runtime.close({ handle: { sessionKey: "agent:codex:acp:binding:test", backend: "acpx", runtimeSessionName: "agent:codex:acp:binding:test", }, reason: "user-close", }), ).rejects.toThrow("cleanup failed"); expect(leaseStore.leases.get("lease-close-live")).toMatchObject({ rootPid: 777, state: "open", }); }); it.each([ { evidence: "process listing is unavailable", processCleanup: { listProcesses: vi.fn(async () => { throw new Error("process listing unavailable"); }), }, }, { evidence: "Windows process evidence is unsupported", processCleanup: { platform: "win32" as const, listProcesses: vi.fn(async () => []), }, }, ])("keeps close leases retryable when $evidence", async ({ processCleanup }) => { const leasedCommand = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-close-process-list ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`; const baseStore: TestSessionStore = { load: vi.fn(async () => ({ name: "agent:codex:acp:binding:test", agentCommand: leasedCommand, pid: 777, })), save: vi.fn(async () => {}), }; const leaseStore = makeLeaseStore(); leaseStore.leases.set("lease-close-process-list", { leaseId: "lease-close-process-list", 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 { runtime, delegate } = makeRuntime( baseStore, { openclawGatewayInstanceId: "gateway-test", openclawProcessLeaseStore: leaseStore.store, openclawWrapperRoot: "/tmp/openclaw/acpx", }, { openclawProcessCleanup: { ...processCleanup, 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(leaseStore.leases.get("lease-close-process-list")).toMatchObject({ rootPid: 777, state: "open", }); if ("platform" in processCleanup && processCleanup.platform === "win32") { expect(processCleanup.listProcesses).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("cleans up non-lease-aware wrapper commands through fallback close cleanup", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ acpxRecordId: "agent:codex:acp:binding:test", agentCommand: CODEX_ACP_WRAPPER_COMMAND, pid: 920, })), save: vi.fn(async () => {}), }; const killed: Array<{ pid: number; signal: NodeJS.Signals }> = []; const { runtime, delegate } = makeRuntime( baseStore, { openclawGatewayInstanceId: "gateway-test", openclawWrapperRoot: "/tmp/openclaw/acpx", }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => [ { pid: 920, ppid: 1, command: CODEX_ACP_WRAPPER_COMMAND, }, { pid: 921, ppid: 920, 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: 921, signal: "SIGTERM" }, { pid: 920, signal: "SIGTERM" }, ]); }); it("uses session lease metadata for fallback close cleanup identity checks", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ acpxRecordId: "agent:codex:acp:binding:test", agentCommand: 'node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"', openclawGatewayInstanceId: "gateway-test", openclawLeaseId: "lease-record", pid: 920, })), save: vi.fn(async () => {}), }; const killed: Array<{ pid: number; signal: NodeJS.Signals }> = []; const { runtime, delegate } = makeRuntime( baseStore, { openclawGatewayInstanceId: "gateway-test", openclawWrapperRoot: "/tmp/openclaw/acpx", }, { openclawProcessCleanup: { listProcesses: vi.fn(async () => [ { pid: 920, ppid: 1, command: `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} other-lease ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`, }, ]), 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[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>[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(); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */