Skills (#50)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPackagedSkillStatusEntry, listPackagedSkills } from "@/lib/skills/catalog";
|
||||
import { resolveSkillMarketplaceMetadata } from "@/lib/skills/marketplace";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
|
||||
const resolveAssetDir = (packageId: string) => path.join(process.cwd(), "assets/skills", packageId);
|
||||
|
||||
describe("packaged skills", () => {
|
||||
it("keeps packaged skill files synchronized with the asset source files", () => {
|
||||
for (const packagedSkill of listPackagedSkills()) {
|
||||
const assetDir = resolveAssetDir(packagedSkill.packageId);
|
||||
const expectedFiles = readdirSync(assetDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const packagedFiles = readPackagedSkillFiles(packagedSkill.packageId);
|
||||
const packagedRelativePaths = packagedFiles.map((file) => file.relativePath).sort();
|
||||
|
||||
expect(packagedRelativePaths).toEqual(expectedFiles);
|
||||
|
||||
for (const file of packagedFiles) {
|
||||
const assetContent = readFileSync(path.join(assetDir, file.relativePath), "utf8");
|
||||
expect(file.content).toBe(assetContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes creator attribution and hides fake popularity stats for packaged skills", () => {
|
||||
for (const packagedSkill of listPackagedSkills()) {
|
||||
expect(packagedSkill.creatorName).toBeTruthy();
|
||||
expect(packagedSkill.creatorUrl).toBeTruthy();
|
||||
|
||||
const metadata = resolveSkillMarketplaceMetadata(buildPackagedSkillStatusEntry(packagedSkill));
|
||||
|
||||
expect(metadata.poweredByName).toBe(packagedSkill.creatorName);
|
||||
expect(metadata.poweredByUrl).toBe(packagedSkill.creatorUrl);
|
||||
expect(metadata.hideStats).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildOfficeSkillTriggerHoldMaps,
|
||||
DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY,
|
||||
OFFICE_SKILL_TRIGGER_PLACE_REGISTRY,
|
||||
} from "@/lib/office/places";
|
||||
import {
|
||||
listPackagedSkillTriggerDefinitions,
|
||||
resolveTriggeredSkillDefinition,
|
||||
} from "@/lib/skills/triggers";
|
||||
|
||||
describe("skill triggers", () => {
|
||||
it("parses packaged skill trigger definitions from SKILL.md", () => {
|
||||
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
|
||||
(entry) => entry.skillKey === "todo-board",
|
||||
);
|
||||
|
||||
expect(todoTrigger).not.toBeUndefined();
|
||||
expect(todoTrigger?.movementTarget).toBe("desk");
|
||||
expect(todoTrigger?.activationPhrases).toContain("todo");
|
||||
expect(todoTrigger?.activationPhrases).toContain("blocked tasks");
|
||||
});
|
||||
|
||||
it("matches the running agent's latest request against enabled skill triggers", () => {
|
||||
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
|
||||
(entry) => entry.skillKey === "todo-board",
|
||||
);
|
||||
|
||||
const matched = resolveTriggeredSkillDefinition({
|
||||
isAgentRunning: true,
|
||||
lastUserMessage: "On telegram, add this to my todo list.",
|
||||
transcriptEntries: [],
|
||||
triggers: todoTrigger ? [todoTrigger] : [],
|
||||
});
|
||||
|
||||
expect(matched?.skillKey).toBe("todo-board");
|
||||
expect(matched?.movementTarget).toBe("desk");
|
||||
});
|
||||
|
||||
it("does not match triggers when the agent is not running", () => {
|
||||
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
|
||||
(entry) => entry.skillKey === "todo-board",
|
||||
);
|
||||
|
||||
const matched = resolveTriggeredSkillDefinition({
|
||||
isAgentRunning: false,
|
||||
lastUserMessage: "Add this to my todo list.",
|
||||
transcriptEntries: [],
|
||||
triggers: todoTrigger ? [todoTrigger] : [],
|
||||
});
|
||||
|
||||
expect(matched).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps trigger places and fallback definitions in one central registry", () => {
|
||||
expect(OFFICE_SKILL_TRIGGER_PLACE_REGISTRY.desk.interactionTarget).toBe("desk");
|
||||
expect(OFFICE_SKILL_TRIGGER_PLACE_REGISTRY.github.interactionTarget).toBe("server_room");
|
||||
expect(DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY["todo-board"]?.movementTarget).toBe("desk");
|
||||
});
|
||||
|
||||
it("builds animation hold maps from the central place registry", () => {
|
||||
const holdMaps = buildOfficeSkillTriggerHoldMaps({
|
||||
"agent-a": "desk",
|
||||
"agent-b": "github",
|
||||
"agent-c": "gym",
|
||||
"agent-d": "qa_lab",
|
||||
});
|
||||
|
||||
expect(holdMaps.deskHoldByAgentId).toEqual({ "agent-a": true });
|
||||
expect(holdMaps.githubHoldByAgentId).toEqual({ "agent-b": true });
|
||||
expect(holdMaps.gymHoldByAgentId).toEqual({ "agent-c": true });
|
||||
expect(holdMaps.qaHoldByAgentId).toEqual({ "agent-d": true });
|
||||
expect(holdMaps.skillGymHoldByAgentId).toEqual({ "agent-c": true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { installPackagedSkillViaGatewayAgent } from "@/lib/skills/install-gateway";
|
||||
|
||||
describe("skills install gateway", () => {
|
||||
it("creates a temporary installer agent and installs a workspace skill", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agents.create") {
|
||||
return { agentId: "installer-1" };
|
||||
}
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "installer-1", tools: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "config.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "agents.list") {
|
||||
return { mainKey: "main" };
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
return { runId: "run-1", status: "started" };
|
||||
}
|
||||
if (method === "agent.wait") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await installPackagedSkillViaGatewayAgent({
|
||||
client: { call } as unknown as GatewayClient,
|
||||
request: {
|
||||
packageId: "todo-board",
|
||||
source: "openclaw-workspace",
|
||||
workspaceDir: "/home/openclaw/workspace-demo",
|
||||
managedSkillsDir: "/home/openclaw/.openclaw/skills",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installed: true,
|
||||
installedPath: "/home/openclaw/workspace-demo/skills/todo-board",
|
||||
source: "openclaw-workspace",
|
||||
skillKey: "todo-board",
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith("agents.create", {
|
||||
name: expect.stringContaining("Skill Installer"),
|
||||
workspace: "/home/openclaw/workspace-demo",
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:installer-1:main",
|
||||
deliver: false,
|
||||
})
|
||||
);
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 60_000 });
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.objectContaining({
|
||||
baseHash: "hash-1",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up the temporary installer agent when install fails", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agents.create") {
|
||||
return { agentId: "installer-2" };
|
||||
}
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-2",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "installer-2", tools: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "agents.list") {
|
||||
return { mainKey: "main" };
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
throw new Error("chat failed");
|
||||
}
|
||||
if (method === "config.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
installPackagedSkillViaGatewayAgent({
|
||||
client: { call } as unknown as GatewayClient,
|
||||
request: {
|
||||
packageId: "todo-board",
|
||||
source: "openclaw-workspace",
|
||||
workspaceDir: "/home/openclaw/workspace-demo",
|
||||
managedSkillsDir: "/home/openclaw/.openclaw/skills",
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("chat failed");
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.objectContaining({
|
||||
baseHash: "hash-2",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
|
||||
|
||||
vi.mock("@/lib/skills/remove-gateway", () => ({
|
||||
removeSkillViaGatewayAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("skills remove client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("posts skill removal payload to the Studio API route", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
result: {
|
||||
removed: true,
|
||||
removedPath: "/tmp/workspace/skills/github",
|
||||
source: "openclaw-workspace",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
it("delegates sanitized skill removal payloads to the gateway-native remover", async () => {
|
||||
vi.mocked(removeSkillViaGatewayAgent).mockResolvedValueOnce({
|
||||
removed: true,
|
||||
removedPath: "/tmp/workspace/skills/github",
|
||||
source: "openclaw-workspace",
|
||||
});
|
||||
|
||||
const result = await removeSkillFromGateway({
|
||||
client: { call: vi.fn() } as never,
|
||||
skillKey: " github ",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: " /tmp/workspace/skills/github ",
|
||||
@@ -30,16 +28,15 @@ describe("skills remove client", () => {
|
||||
managedSkillsDir: " /tmp/managed ",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expect(removeSkillViaGatewayAgent).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
request: {
|
||||
skillKey: "github",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: "/tmp/workspace/skills/github",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/managed",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
removed: true,
|
||||
@@ -51,6 +48,7 @@ describe("skills remove client", () => {
|
||||
it("fails fast when required payload fields are missing", async () => {
|
||||
await expect(
|
||||
removeSkillFromGateway({
|
||||
client: { call: vi.fn() } as never,
|
||||
skillKey: " ",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: "/tmp/workspace/skills/github",
|
||||
@@ -61,6 +59,7 @@ describe("skills remove client", () => {
|
||||
|
||||
await expect(
|
||||
removeSkillFromGateway({
|
||||
client: { call: vi.fn() } as never,
|
||||
skillKey: "github",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: " ",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
|
||||
|
||||
describe("skills remove gateway", () => {
|
||||
it("creates a temporary remover agent and removes a workspace skill", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agents.create") {
|
||||
return { agentId: "remover-1" };
|
||||
}
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "remover-1", tools: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "config.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "agents.list") {
|
||||
return { mainKey: "main" };
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
return { runId: "run-1", status: "started" };
|
||||
}
|
||||
if (method === "agent.wait") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await removeSkillViaGatewayAgent({
|
||||
client: { call } as unknown as GatewayClient,
|
||||
request: {
|
||||
skillKey: "todo-board",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: "/home/openclaw/workspace-demo/skills/todo-board",
|
||||
workspaceDir: "/home/openclaw/workspace-demo",
|
||||
managedSkillsDir: "/home/openclaw/.openclaw/skills",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
removed: true,
|
||||
removedPath: "/home/openclaw/workspace-demo/skills/todo-board",
|
||||
source: "openclaw-workspace",
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith("agents.create", {
|
||||
name: expect.stringContaining("Skill Remover"),
|
||||
workspace: "/home/openclaw/workspace-demo",
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:remover-1:main",
|
||||
deliver: false,
|
||||
}),
|
||||
);
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 60_000 });
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.objectContaining({
|
||||
baseHash: "hash-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the managed skills directory as workspace for managed skill removal", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agents.create") {
|
||||
return { agentId: "remover-2" };
|
||||
}
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-2",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "remover-2", tools: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "config.patch") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "agents.list") {
|
||||
return { mainKey: "main" };
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
return { runId: "run-2", status: "started" };
|
||||
}
|
||||
if (method === "agent.wait") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await removeSkillViaGatewayAgent({
|
||||
client: { call } as unknown as GatewayClient,
|
||||
request: {
|
||||
skillKey: "github",
|
||||
source: "openclaw-managed",
|
||||
baseDir: "/home/openclaw/.openclaw/skills/github",
|
||||
workspaceDir: "/home/openclaw/workspace-demo",
|
||||
managedSkillsDir: "/home/openclaw/.openclaw/skills",
|
||||
},
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith("agents.create", {
|
||||
name: expect.stringContaining("Skill Remover"),
|
||||
workspace: "/home/openclaw/.openclaw/skills",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user