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