import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/acpx"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js"; import { __testing, getAcpRuntimeBackend, requireAcpRuntimeBackend, } from "../../../src/acp/runtime/registry.js"; import { ACPX_BUNDLED_BIN, ACPX_PINNED_VERSION } from "./config.js"; import { createAcpxRuntimeService } from "./service.js"; const { ensureAcpxSpy } = vi.hoisted(() => ({ ensureAcpxSpy: vi.fn(async () => {}), })); vi.mock("./ensure.js", () => ({ ensureAcpx: ensureAcpxSpy, })); type RuntimeStub = AcpRuntime & { probeAvailability(): Promise; isHealthy(): boolean; }; function createRuntimeStub(healthy: boolean): { runtime: RuntimeStub; probeAvailabilitySpy: ReturnType; isHealthySpy: ReturnType; } { const probeAvailabilitySpy = vi.fn(async () => {}); const isHealthySpy = vi.fn(() => healthy); return { runtime: { ensureSession: vi.fn(async (input) => ({ sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: input.sessionKey, })), runTurn: vi.fn(async function* () { yield { type: "done" as const }; }), cancel: vi.fn(async () => {}), close: vi.fn(async () => {}), async probeAvailability() { await probeAvailabilitySpy(); }, isHealthy() { return isHealthySpy(); }, }, probeAvailabilitySpy, isHealthySpy, }; } function createServiceContext( overrides: Partial = {}, ): OpenClawPluginServiceContext { return { config: {}, workspaceDir: "/tmp/workspace", stateDir: "/tmp/state", logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, ...overrides, }; } describe("createAcpxRuntimeService", () => { beforeEach(() => { __testing.resetAcpRuntimeBackendsForTests(); ensureAcpxSpy.mockReset(); ensureAcpxSpy.mockImplementation(async () => {}); }); it("registers and unregisters the acpx backend", async () => { const { runtime, probeAvailabilitySpy } = createRuntimeStub(true); const service = createAcpxRuntimeService({ runtimeFactory: () => runtime, }); const context = createServiceContext(); await service.start(context); expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); await vi.waitFor(() => { expect(ensureAcpxSpy).toHaveBeenCalledOnce(); expect(ensureAcpxSpy).toHaveBeenCalledWith( expect.objectContaining({ stripProviderAuthEnvVars: true, }), ); expect(probeAvailabilitySpy).toHaveBeenCalledOnce(); }); await service.stop?.(context); expect(getAcpRuntimeBackend("acpx")).toBeNull(); }); it("marks backend unavailable when runtime health check fails", async () => { const { runtime } = createRuntimeStub(false); const service = createAcpxRuntimeService({ runtimeFactory: () => runtime, }); const context = createServiceContext(); await service.start(context); expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError); try { requireAcpRuntimeBackend("acpx"); throw new Error("expected ACP backend lookup to fail"); } catch (error) { expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE"); } }); it("passes queue-owner TTL from plugin config", async () => { const { runtime } = createRuntimeStub(true); const runtimeFactory = vi.fn(() => runtime); const service = createAcpxRuntimeService({ runtimeFactory, pluginConfig: { queueOwnerTtlSeconds: 0.25, }, }); const context = createServiceContext(); await service.start(context); expect(runtimeFactory).toHaveBeenCalledWith( expect.objectContaining({ queueOwnerTtlSeconds: 0.25, pluginConfig: expect.objectContaining({ command: ACPX_BUNDLED_BIN, expectedVersion: ACPX_PINNED_VERSION, allowPluginLocalInstall: true, }), }), ); }); it("uses a short default queue-owner TTL", async () => { const { runtime } = createRuntimeStub(true); const runtimeFactory = vi.fn(() => runtime); const service = createAcpxRuntimeService({ runtimeFactory, }); const context = createServiceContext(); await service.start(context); expect(runtimeFactory).toHaveBeenCalledWith( expect.objectContaining({ queueOwnerTtlSeconds: 0.1, }), ); }); it("does not block startup while acpx ensure runs", async () => { const { runtime } = createRuntimeStub(true); ensureAcpxSpy.mockImplementation(() => new Promise(() => {})); const service = createAcpxRuntimeService({ runtimeFactory: () => runtime, }); const context = createServiceContext(); const startResult = await Promise.race([ Promise.resolve(service.start(context)).then(() => "started"), new Promise((resolve) => setTimeout(() => resolve("timed_out"), 100)), ]); expect(startResult).toBe("started"); expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); }); });