import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createOpenClawTestState, type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; const tempDirs = createTrackedTempDirs(); let testState: OpenClawTestState; const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), config: {} as { gateway?: { mode: "local" | "remote" } }, gatewayApply: undefined as | ((request: { method: string; params?: { proposalId?: string } }) => Promise) | undefined, acquireGatewayLock: vi.fn(), releaseGatewayLock: vi.fn(), workspaceDir: "", defaultRuntime: { log: vi.fn(), error: vi.fn(), writeStdout: vi.fn(), writeJson: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`__exit__:${code}`); }), }, })); vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime })); vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway, isGatewayCredentialsRequiredError: (error: unknown) => error instanceof Error && error.name === "GatewayCredentialsRequiredError", isGatewayTransportError: () => false, })); vi.mock("../infra/gateway-lock.js", () => ({ acquireGatewayLock: mocks.acquireGatewayLock, })); vi.mock("../config/config.js", () => ({ getRuntimeConfig: () => mocks.config, resetConfigRuntimeState: () => undefined, })); vi.mock("../agents/agent-scope.js", () => ({ resolveAgentIdByWorkspacePath: () => undefined, resolveDefaultAgentId: () => "main", resolveAgentWorkspaceDir: () => mocks.workspaceDir, })); describe("skills workshop CLI gateway snapshot invalidation", () => { beforeEach(async () => { testState = await createOpenClawTestState({ layout: "state-only", prefix: "openclaw-skills-cli-workshop-cache-", }); mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-cache-"); delete mocks.config.gateway; mocks.gatewayApply = undefined; mocks.releaseGatewayLock.mockReset(); mocks.acquireGatewayLock.mockReset().mockResolvedValue({ release: mocks.releaseGatewayLock }); mocks.defaultRuntime.error.mockClear(); mocks.defaultRuntime.exit.mockClear(); mocks.callGateway.mockReset().mockImplementation(async (request) => { if (request.method === "health") { return {}; } if (!mocks.gatewayApply) { throw new Error("gateway unavailable"); } return await mocks.gatewayApply(request); }); vi.resetModules(); }); afterEach(async () => { await testState.cleanup(); await tempDirs.cleanup(); vi.resetModules(); }); it("applies through the gateway process that owns the cached session skill index", async () => { // This first module graph stands in for the long-running Gateway process. const gatewaySnapshots = await import("../skills/runtime/session-snapshot.js"); const gatewayRefreshState = await import("../skills/runtime/refresh-state.js"); const gatewayWorkshop = await import("../skills/workshop/service.js"); const proposal = await gatewayWorkshop.proposeCreateSkill({ workspaceDir: mocks.workspaceDir, name: "Gateway Visible", description: "Visible in sessions without restarting the gateway", content: "# Gateway Visible\n\nUse the newly applied workflow.\n", }); const beforeApply = gatewaySnapshots.resolveReusableWorkspaceSkillSnapshot({ workspaceDir: mocks.workspaceDir, config: mocks.config, watch: false, }).snapshot; expect(beforeApply.skills.map((skill) => skill.name)).not.toContain("gateway-visible"); // Session persistence strips resolvedSkills; the Gateway rehydrates that field // from its process cache when the snapshot version has not advanced. const { resolvedSkills: _runtimeOnly, ...persistedSnapshot } = beforeApply; const beforeVersion = gatewayRefreshState.getSkillsSnapshotVersion(mocks.workspaceDir); mocks.gatewayApply = async (request) => { expect(request.method).toBe("skills.proposals.apply"); return await gatewayWorkshop.applySkillProposal({ workspaceDir: mocks.workspaceDir, config: mocks.config, proposalId: request.params?.proposalId ?? "", }); }; // A fresh module graph models the short-lived CLI process. Direct application // here would bump only the CLI's refresh-state map, leaving the Gateway stale. vi.resetModules(); const { registerSkillsCli } = await import("./skills-cli.js"); const program = new Command(); program.exitOverride(); registerSkillsCli(program); await program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user", }); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "skills.proposals.apply", params: { agentId: "main", proposalId: proposal.record.id }, timeoutMs: 1_850_000, }), ); expect(gatewayRefreshState.getSkillsSnapshotVersion(mocks.workspaceDir)).toBeGreaterThan( beforeVersion, ); const newSession = gatewaySnapshots.resolveReusableWorkspaceSkillSnapshot({ workspaceDir: mocks.workspaceDir, config: mocks.config, existingSnapshot: persistedSnapshot, watch: false, }).snapshot; expect(newSession.skills.map((skill) => skill.name)).toContain("gateway-visible"); }); it("does not replay a dispatched gateway apply failure in the CLI process", async () => { const workshop = await import("../skills/workshop/service.js"); const proposal = await workshop.proposeCreateSkill({ workspaceDir: mocks.workspaceDir, name: "Single Dispatch", description: "Apply only in the process that owns snapshot state", content: "# Single Dispatch\n\nDo not replay this mutation.\n", }); mocks.gatewayApply = async () => { throw new Error("gateway apply failed"); }; vi.resetModules(); const { registerSkillsCli } = await import("./skills-cli.js"); const program = new Command(); program.exitOverride(); registerSkillsCli(program); await expect( program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user" }), ).rejects.toThrow("__exit__:1"); expect(mocks.callGateway.mock.calls.map(([request]) => request.method)).toEqual([ "health", "skills.proposals.apply", ]); await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({ record: { status: "pending" }, }); }); it("preserves configless offline apply when no local gateway is listening", async () => { const workshop = await import("../skills/workshop/service.js"); const proposal = await workshop.proposeCreateSkill({ workspaceDir: mocks.workspaceDir, name: "Offline Upgrade", description: "Keep shipped configless Workshop apply behavior", content: "# Offline Upgrade\n\nApply without a running gateway.\n", }); const authError = Object.assign(new Error("gateway health requires credentials"), { name: "GatewayCredentialsRequiredError", method: "health", configPath: "/tmp/openclaw.json", }); mocks.callGateway.mockRejectedValueOnce(authError); vi.resetModules(); const { registerSkillsCli } = await import("./skills-cli.js"); const program = new Command(); program.exitOverride(); registerSkillsCli(program); await program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user", }); expect(mocks.callGateway).toHaveBeenCalledTimes(1); expect(mocks.acquireGatewayLock).toHaveBeenCalledWith({ allowInTests: true, port: 18789, role: "skill-workshop-apply", timeoutMs: 250, }); expect(mocks.releaseGatewayLock).toHaveBeenCalledTimes(1); await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({ record: { status: "applied" }, }); }); it("does not bypass Gateway ownership when CLI credentials are missing", async () => { const workshop = await import("../skills/workshop/service.js"); const proposal = await workshop.proposeCreateSkill({ workspaceDir: mocks.workspaceDir, name: "Gateway Owned Upgrade", description: "Keep snapshot invalidation in the running gateway", content: "# Gateway Owned Upgrade\n\nDo not apply in the CLI process.\n", }); const authError = Object.assign(new Error("gateway health requires credentials"), { name: "GatewayCredentialsRequiredError", method: "health", configPath: "/tmp/openclaw.json", }); mocks.callGateway.mockRejectedValueOnce(authError); mocks.acquireGatewayLock.mockRejectedValueOnce(new Error("gateway lock is owned")); vi.resetModules(); const { registerSkillsCli } = await import("./skills-cli.js"); const program = new Command(); program.exitOverride(); registerSkillsCli(program); await expect( program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user" }), ).rejects.toThrow("__exit__:1"); expect(mocks.callGateway).toHaveBeenCalledTimes(1); expect(mocks.acquireGatewayLock).toHaveBeenCalledTimes(1); expect(mocks.releaseGatewayLock).not.toHaveBeenCalled(); await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({ record: { status: "pending" }, }); }); it("evaluates the exact inspected draft through the gateway plugin registry", async () => { mocks.gatewayApply = async (request) => { if (request.method === "skills.proposals.inspect") { return { record: { id: "proposal-evaluate", draftHash: "a".repeat(64), }, revisionHash: "b".repeat(64), content: "# Evaluate\n", }; } expect(request).toMatchObject({ method: "skills.proposals.evaluate", params: { agentId: "main", proposalId: "proposal-evaluate", expectedRevisionHash: "b".repeat(64), correlationId: "optimizer-run-7", }, timeoutMs: 650_000, }); return { record: { id: "proposal-evaluate" }, evaluation: { proposedVersion: "0.2.0", revisionHash: "b".repeat(64), outcomes: [ { evaluatorId: "skill-spector", pluginId: "nvidia-evals", pluginVersion: "1.2.3", status: "completed", result: { decision: "revise", summary: "Tighten the trigger." }, }, ], }, }; }; vi.resetModules(); const { registerSkillsCli } = await import("./skills-cli.js"); const program = new Command(); program.exitOverride(); registerSkillsCli(program); await program.parseAsync( [ "skills", "workshop", "evaluate", "proposal-evaluate", "--correlation-id", "optimizer-run-7", ], { from: "user" }, ); expect(mocks.callGateway.mock.calls.map(([request]) => request.method)).toEqual([ "skills.proposals.inspect", "skills.proposals.evaluate", ]); expect(mocks.defaultRuntime.writeStdout).toHaveBeenCalledWith( expect.stringContaining( "skill-spector (nvidia-evals@1.2.3) completed revise: Tighten the trigger.", ), ); }); });