evOo-local/apps/api/src/agent-skills.test.ts
cynarAI f3b47cb7cd
Tell the agent what it can reach and how to work reliably (#198)
Agents denied a connected GitHub and did not know they could install
skills: the prompt said "No plugins are connected yet." whenever only
installed tool sources existed, and the skill catalog never mentioned
skill_install. Connections and skills were also bound to the space they
were set up in, so a project never saw them.

- Name every connected app from the tools the run actually received,
  including lazy catalogs and how to reach them.
- Tool sources and skills follow the user into every space; a skill in
  the current space wins a name clash.
- Add shared working principles (do the work, verify before claiming,
  keep it simple, build a missing tool as a tested script plus skill).
- Rewrite the skill catalog in plain words, mention skill_install, clip
  long descriptions and count skills past the limit.
- Routine runs learn that nobody is waiting and history is not shown;
  schedule prompts must be self-contained.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 15:33:57 +02:00

139 lines
5.5 KiB
TypeScript

import type { Actor } from "@rakazo/contracts";
import { buildSkillMd } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { createAgentSkillsService } from "./agent-skills.js";
const actor: Actor = {
spaceId: "space-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
};
function savedSkill(name: string, source = "user") {
return {
id: "saved-1",
spaceId: actor.spaceId,
userId: actor.userId,
name,
description: "Saved review recipe",
content: buildSkillMd({ name, description: "Saved review recipe", body: "Saved steps" }),
source,
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
Object.entries(where).every(([key, value]) =>
key === "name" && typeof value === "object" && value && "equals" in value
? row.name.toLowerCase() === String(value.equals).toLowerCase()
: key === "NOT" || row[key as keyof typeof row] === value,
);
const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)),
),
findFirst: vi.fn(async ({ where }: { where: Where }) =>
rows.find((row) => matches(row, where)),
),
updateMany: vi.fn(async ({ where, data }: { where: Where; data: object }) => {
const row = rows.find((row) => matches(row, where));
if (!row) return { count: 0 };
Object.assign(row, data);
return { count: 1 };
}),
deleteMany: vi.fn(async ({ where }: { where: Where }) => {
const index = rows.findIndex((row) => matches(row, where));
if (index < 0) return { count: 0 };
rows.splice(index, 1);
return { count: 1 };
}),
};
return {
agentSkill,
service: createAgentSkillsService({ agentSkill } as unknown as PrismaClient),
};
}
/** Only the entries competing for the name "Interrogate"; other builtins are irrelevant here. */
const interrogate = <T extends { name: string }>(skills: T[]) =>
skills.filter((skill) => skill.name.trim().toLowerCase() === "interrogate");
describe("built-in skill precedence in the API", () => {
it.each(["interrogate", " Interrogate "])(
"keeps %j listed, readable and mutable",
async (name) => {
const { service } = setup([savedSkill(name)]);
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]);
expect(interrogate(await service.listWithContent(actor))).toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
id: "saved-1",
source: "user",
});
await expect(service.get(actor, { skillId: "saved-1" })).resolves.toMatchObject({ name });
await expect(
service.update(actor, { skillId: "saved-1", description: "Updated recipe" }),
).resolves.toMatchObject({ name: name.trim(), description: "Updated recipe" });
await expect(service.remove(actor, "saved-1")).resolves.toEqual({ ok: true });
await expect(service.get(actor, { name: "Interrogate" })).resolves.toMatchObject({
id: "builtin:Interrogate",
readOnly: true,
});
},
);
it("preserves plugin precedence and read-only enforcement", async () => {
const { service, agentSkill } = setup([savedSkill(" Interrogate ", "plugin")]);
await expect(service.get(actor, { name: "interrogate" })).resolves.toMatchObject({
id: "saved-1",
source: "plugin",
readOnly: true,
});
await expect(service.update(actor, { skillId: "saved-1", body: "Changed" })).rejects.toThrow(
"read-only",
);
await expect(service.remove(actor, "saved-1")).rejects.toThrow("read-only");
expect(agentSkill.updateMany).not.toHaveBeenCalled();
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
});
it("lists the user's skills from other spaces too, the current space winning a clash", async () => {
const { service } = setup([
{ ...savedSkill("Launch"), id: "home-1", spaceId: "home" },
{ ...savedSkill("launch"), id: "here-1" },
{ ...savedSkill("Weekly review"), id: "home-2", spaceId: "home" },
]);
const own = (await service.list(actor)).filter((skill) => skill.source === "user");
expect(own.map((skill) => skill.id).sort()).toEqual(["here-1", "home-2"]);
await expect(service.get(actor, { name: "Weekly review" })).resolves.toMatchObject({
id: "home-2",
});
await expect(
service.create(actor, { name: "weekly REVIEW", description: "x" }),
).rejects.toThrow("already exists");
});
it.each([{ userId: "other-user" }])(
"does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
await expect(service.get(actor, { name: "interrogate" })).resolves.toMatchObject({
id: "builtin:Interrogate",
readOnly: true,
});
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }),
]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();
},
);
});