evOo-local/packages/adapters/src/skill-tools.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

337 lines
12 KiB
TypeScript

import { buildSkillMd, formatSkillsCatalogInstruction, parseSkillMd } from "@rakazo/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BUILTIN_AGENT_SKILLS } from "./builtin-skills.js";
import {
listAgentSkillRecords,
skillCreateFromTool,
skillDeleteFromTool,
skillReadFromTool,
skillUpdateFromTool,
} from "./skill-tools.js";
function makePrisma(rows: Array<Record<string, unknown>> = []) {
const store = [...rows];
return {
agentSkill: {
findMany: vi.fn(async ({ where }: { where?: Record<string, string> } = {}) =>
store
.filter((row) => {
if (!where) return true;
if (where.spaceId && row.spaceId !== where.spaceId) return false;
if (where.userId && row.userId !== where.userId) return false;
return true;
})
.map((row) => ({ ...row })),
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = {
id: `skill-${store.length + 1}`,
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
...data,
};
store.push(row);
return row;
}),
update: vi.fn(
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
const index = store.findIndex((row) => row.id === where.id);
if (index < 0) throw new Error("missing");
store[index] = { ...store[index], ...data, updatedAt: new Date() };
return store[index];
},
),
updateMany: vi.fn(
async ({
where,
data,
}: {
where: Record<string, string>;
data: Record<string, unknown>;
}) => {
const index = store.findIndex(
(row) =>
row.id === where.id &&
row.spaceId === where.spaceId &&
row.userId === where.userId &&
row.source === where.source,
);
if (index < 0) return { count: 0 };
store[index] = { ...store[index], ...data, updatedAt: new Date() };
return { count: 1 };
},
),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const index = store.findIndex((row) => row.id === where.id);
if (index < 0) throw new Error("missing");
const [removed] = store.splice(index, 1);
return removed;
}),
deleteMany: vi.fn(async ({ where }: { where: Record<string, string> }) => {
const index = store.findIndex(
(row) =>
row.id === where.id &&
row.spaceId === where.spaceId &&
row.userId === where.userId &&
row.source === where.source,
);
if (index < 0) return { count: 0 };
store.splice(index, 1);
return { count: 1 };
}),
},
_store: store,
};
}
const owner = { spaceId: "ws-1", userId: "user-1" };
describe("skill tools", () => {
let prisma: ReturnType<typeof makePrisma>;
beforeEach(() => {
prisma = makePrisma();
});
it("creates, reads, updates, and deletes user skills", async () => {
const created = await skillCreateFromTool(prisma as never, owner, {
name: "Daily standup",
description: "Prepare standup notes",
body: "1. Summarize yesterday.\n2. List blockers.",
});
expect(created).toMatchObject({ ok: true, name: "Daily standup" });
expect(String(created.hint)).toContain("/Daily standup");
const read = await skillReadFromTool(prisma as never, owner, { name: "Daily standup" });
expect(read).toMatchObject({
name: "Daily standup",
description: "Prepare standup notes",
readOnly: false,
});
expect(String(read.content)).toContain("Summarize yesterday");
const updated = await skillUpdateFromTool(prisma as never, owner, {
name: "Daily standup",
description: "Updated when-to-use",
});
expect(updated).toMatchObject({ ok: true, description: "Updated when-to-use" });
const deleted = await skillDeleteFromTool(prisma as never, owner, { name: "Daily standup" });
expect(deleted).toEqual({ ok: true, name: "Daily standup" });
expect(await skillReadFromTool(prisma as never, owner, { name: "Daily standup" })).toEqual({
error: "Skill not found.",
});
});
it("preserves extra frontmatter keys on update", async () => {
const content = buildSkillMd({
name: "Review PR",
description: "Review a pull request",
body: "1. Read the diff.",
frontmatter: { compatibility: "cursor", "allowed-tools": ["shell"] },
});
await skillCreateFromTool(prisma as never, owner, { content });
await skillUpdateFromTool(prisma as never, owner, {
name: "Review PR",
body: "1. Read the diff.\n2. Leave comments.",
});
const read = await skillReadFromTool(prisma as never, owner, { name: "Review PR" });
expect(String(read.content)).toContain("compatibility: cursor");
expect(String(read.content)).toContain("Leave comments");
});
it("renames only via newName, not the name lookup key", async () => {
const created = await skillCreateFromTool(prisma as never, owner, {
name: "Original",
description: "Keep name unless newName",
body: "body",
});
const skillId = String(created.id);
const mistyped = await skillUpdateFromTool(prisma as never, owner, {
skillId,
name: "Should not apply",
description: "still original name",
});
expect(mistyped).toMatchObject({ ok: true, name: "Original" });
const renamed = await skillUpdateFromTool(prisma as never, owner, {
skillId,
newName: "Renamed",
});
expect(renamed).toMatchObject({ ok: true, name: "Renamed" });
});
it("scopes list to the owner workspace and injects catalog lines", async () => {
prisma = makePrisma([
{
id: "other-1",
spaceId: "ws-2",
userId: "user-2",
name: "Other workspace skill",
description: "not visible",
content: buildSkillMd({
name: "Other workspace skill",
description: "not visible",
body: "x",
}),
source: "user",
},
]);
await skillCreateFromTool(prisma as never, owner, {
name: "Daily standup",
description: "Prepare standup notes",
body: "steps",
});
const records = await listAgentSkillRecords(prisma as never, owner);
expect(records.map((row) => row.name)).toContain("Daily standup");
expect(records.map((row) => row.name)).not.toContain("Other workspace skill");
const catalog = formatSkillsCatalogInstruction(records);
expect(catalog).toContain("- Daily standup: Prepare standup notes");
expect(catalog).toContain("skill_read");
});
it("rejects update/delete for plugin and builtin skills", async () => {
prisma = makePrisma([
{
id: "plugin-1",
spaceId: owner.spaceId,
userId: owner.userId,
name: "Plugin recipe",
description: "From a plugin",
content: buildSkillMd({
name: "Plugin recipe",
description: "From a plugin",
body: "do not edit",
}),
source: "plugin",
},
]);
expect(
await skillUpdateFromTool(prisma as never, owner, {
name: "Plugin recipe",
description: "hijack",
}),
).toEqual({ error: "Builtin and plugin skills are read-only." });
expect(await skillDeleteFromTool(prisma as never, owner, { name: "Plugin recipe" })).toEqual({
error: "Builtin and plugin skills are read-only.",
});
});
it("ships builtin skills as valid, readable, read-only SKILL.md records", async () => {
expect(BUILTIN_AGENT_SKILLS.map((skill) => skill.name)).toContain("Interrogate");
for (const skill of BUILTIN_AGENT_SKILLS) {
const parsed = parseSkillMd(skill.content);
expect(parsed).toMatchObject({ name: skill.name, description: skill.description });
// / picker truncates at 72; keep descriptions short so "review only" stays visible.
expect(skill.description.length).toBeLessThanOrEqual(72);
}
const records = await listAgentSkillRecords(prisma as never, owner);
const interrogate = records.find((row) => row.name === "Interrogate");
expect(interrogate).toMatchObject({
id: "builtin:Interrogate",
source: "builtin",
readOnly: true,
});
expect(formatSkillsCatalogInstruction(records)).toContain("- Interrogate:");
expect(formatSkillsCatalogInstruction(records)).toContain("Review only");
const read = await skillReadFromTool(prisma as never, owner, { name: "interrogate" });
expect(read).toMatchObject({ name: "Interrogate", source: "builtin", readOnly: true });
expect(String(read.content)).toContain("Do not modify files");
expect(
await skillCreateFromTool(prisma as never, owner, {
name: "interrogate",
description: "shadow the builtin",
body: "steps",
}),
).toEqual({ error: 'A skill named "Interrogate" already exists.' });
expect(
await skillUpdateFromTool(prisma as never, owner, {
name: "Interrogate",
description: "hijack",
}),
).toEqual({ error: "Builtin and plugin skills are read-only." });
expect(await skillDeleteFromTool(prisma as never, owner, { name: "Interrogate" })).toEqual({
error: "Builtin and plugin skills are read-only.",
});
});
it.each(["interrogate", " Interrogate "])("preserves a saved skill named %j", async (name) => {
prisma = makePrisma([
{
id: "legacy-1",
spaceId: owner.spaceId,
userId: owner.userId,
name,
description: "my review recipe",
content: buildSkillMd({
name,
description: "my review recipe",
body: "my steps",
}),
source: "user",
},
]);
const records = await listAgentSkillRecords(prisma as never, owner);
expect(records.filter((skill) => skill.name.trim().toLowerCase() === "interrogate")).toEqual([
expect.objectContaining({ id: "legacy-1", source: "user", readOnly: false }),
]);
expect(await skillReadFromTool(prisma as never, owner, { name: "Interrogate" })).toMatchObject({
name,
source: "user",
readOnly: false,
});
expect(await skillReadFromTool(prisma as never, owner, { skillId: "legacy-1" })).toMatchObject({
name,
source: "user",
});
expect(
await skillUpdateFromTool(prisma as never, owner, {
name: "Interrogate",
description: "still mine",
}),
).toMatchObject({ ok: true });
expect(await skillDeleteFromTool(prisma as never, owner, { name: "Interrogate" })).toEqual({
ok: true,
name: name.trim(),
});
});
it("rejects oversized skill content", async () => {
const body = "x".repeat(100_001);
const created = await skillCreateFromTool(prisma as never, owner, {
name: "Huge",
description: "too big",
body,
});
expect(created).toMatchObject({ error: expect.stringContaining("at most") });
});
it("rejects overlong skill names on structured create and rename", async () => {
const longName = "N".repeat(81);
expect(
await skillCreateFromTool(prisma as never, owner, {
name: longName,
description: "ok description",
body: "steps",
}),
).toEqual({ error: "Skill name must be at most 80 characters." });
await skillCreateFromTool(prisma as never, owner, {
name: "Short",
description: "ok description",
body: "steps",
});
expect(
await skillUpdateFromTool(prisma as never, owner, {
name: "Short",
newName: longName,
}),
).toEqual({ error: "Skill name must be at most 80 characters." });
});
});