能力 ID 的域名允许单个下划线分词(episode_mapping)。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
151 lines
6.3 KiB
TypeScript
151 lines
6.3 KiB
TypeScript
import { CAPABILITY_IDS } from "@umt/contracts";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
buildLayeredPrompt,
|
|
GENERAL_CAPABILITIES,
|
|
INTENT_ROUTES,
|
|
loadSkillDocs,
|
|
type SkillDoc,
|
|
selectSkills,
|
|
withSkillInvocation,
|
|
} from "../src/index.ts";
|
|
|
|
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md 的加载、格式、能力覆盖与按意图选择。
|
|
|
|
let skills: SkillDoc[] = [];
|
|
let diagnostics: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const r = await loadSkillDocs();
|
|
skills = r.skills;
|
|
diagnostics = r.diagnostics;
|
|
});
|
|
|
|
const REQUIRED_SECTIONS = ["## 适用意图", "## 步骤", "## 需要批准", "## 汇报模板", "## 不要做"];
|
|
|
|
describe("加载(Pi harness loadSkills)", () => {
|
|
it("至少 5 个 Skill,没有格式诊断", () => {
|
|
expect(diagnostics).toEqual([]);
|
|
expect(skills.length).toBeGreaterThanOrEqual(5);
|
|
expect(skills.map((s) => s.name)).toEqual(
|
|
expect.arrayContaining([
|
|
"patrol",
|
|
"acquire-title",
|
|
"import-troubleshoot",
|
|
"subscription-check",
|
|
"storage-cleanup",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("每个 Skill 有中文标题、描述、触发词、能力,正文五节齐全", () => {
|
|
for (const s of skills) {
|
|
expect(s.title, s.name).toMatch(/[一-龥]/);
|
|
expect(s.description.length, s.name).toBeGreaterThan(10);
|
|
expect(s.triggers.length, s.name).toBeGreaterThan(0);
|
|
expect(s.capabilities.length, s.name).toBeGreaterThan(0);
|
|
for (const h of REQUIRED_SECTIONS) expect(s.content, `${s.name} 缺 ${h}`).toContain(h);
|
|
}
|
|
});
|
|
|
|
it("正文提到的能力都在 frontmatter 的 capabilities 里,且都是已知能力", () => {
|
|
for (const s of skills) {
|
|
const mentioned = new Set(s.content.match(/\b[a-z][a-z_]*\.[a-z_]+\b/g) ?? []);
|
|
for (const m of mentioned) {
|
|
if (!(CAPABILITY_IDS as readonly string[]).includes(m)) continue;
|
|
expect(s.capabilities, `${s.name} 正文用了 ${m}`).toContain(m);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("汇报模板里的示例本身不带内部字段(UUID、原始字节数、能力名)", () => {
|
|
for (const s of skills) {
|
|
const tpl = s.content.slice(s.content.indexOf("## 汇报模板"), s.content.indexOf("## 不要做"));
|
|
const quotes = tpl
|
|
.split("\n")
|
|
.filter((l) => l.startsWith("> "))
|
|
.join("\n");
|
|
expect(quotes, s.name).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}|\d{7,}/);
|
|
for (const id of CAPABILITY_IDS) expect(quotes, `${s.name} 模板含 ${id}`).not.toContain(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("能力覆盖", () => {
|
|
it("每个能力至少被一个 Skill 引用,或标注为通用", () => {
|
|
const used = new Set(skills.flatMap((s) => s.capabilities));
|
|
const general = new Set<string>(GENERAL_CAPABILITIES);
|
|
const missing = CAPABILITY_IDS.filter((id) => !used.has(id) && !general.has(id));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("每个能力都出现在能力地图的某条路线里,或标注为通用", () => {
|
|
const routed = new Set<string>(INTENT_ROUTES.flatMap((r) => r.steps.map((s) => s.cap)));
|
|
const missing = CAPABILITY_IDS.filter((id) => !routed.has(id) && !(GENERAL_CAPABILITIES as string[]).includes(id));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/** 固定提示集的意图路由(与 e16 评测同一组提示) */
|
|
const ROUTING: [string, string][] = [
|
|
["检查站点、下载器和媒体服务器是否正常", "patrol"],
|
|
["把《沙丘 2》4K 收进来", "acquire-title"],
|
|
["为什么沙丘2没入库", "import-troubleshoot"],
|
|
["订阅追更情况", "subscription-check"],
|
|
["有没有失败的下载任务", "download-diagnose"],
|
|
["空间不够了,看看能清理什么", "storage-cleanup"],
|
|
["qB 是不是掉线了", "patrol"],
|
|
["我订的三体更新到第几集了", "subscription-check"],
|
|
["下载进度怎么样了", "download-diagnose"],
|
|
["帮我建个巡检,每天早上看看有没有失败的下载", "patrol-draft"],
|
|
["每隔 6 小时检查一下站点登录", "patrol-draft"],
|
|
["药屋少女第二季的集数对不上", "episode-mapping"],
|
|
["Lilith 的连续编号帮我对一下", "episode-mapping"],
|
|
];
|
|
|
|
describe("按意图选择", () => {
|
|
it.each(ROUTING)("%s → %s", (goal, name) => {
|
|
const picked = selectSkills(skills, goal, { kind: "chat" });
|
|
expect(picked[0]?.name).toBe(name);
|
|
});
|
|
|
|
it("集数待确认事项的后台调查载入「集数映射」", () => {
|
|
const goal = "调查待处理事项 x(episode_mapping):集数待确认:药屋少女的呢喃 第 1 季 · Lilith-Raws。";
|
|
const picked = selectSkills(skills, goal, {
|
|
kind: "investigate",
|
|
capabilities: ["episode_mapping.inspect", "episode_mapping.simulate", "decision.list"],
|
|
});
|
|
expect(picked[0]?.name).toBe("episode-mapping");
|
|
});
|
|
|
|
it("闲聊不载入 Skill;最多载入 2 个", () => {
|
|
expect(selectSkills(skills, "你好", { kind: "chat" })).toEqual([]);
|
|
expect(selectSkills(skills, "巡检一下下载失败和订阅追更,再清理空间", { kind: "chat" }).length).toBe(2);
|
|
});
|
|
|
|
it("巡检会话固定带「巡检」;可用能力都不在白名单的 Skill 不选", () => {
|
|
const p = selectSkills(skills, "巡检待处理(2026-09-30)", { kind: "patrol", force: ["patrol"] });
|
|
expect(p[0]?.name).toBe("patrol");
|
|
expect(selectSkills(skills, "把《沙丘 2》4K 收进来", { kind: "chat", capabilities: ["site.list"] })).toEqual([]);
|
|
// 收录作品只在用户会话和子会话里用
|
|
expect(selectSkills(skills, "把《沙丘 2》4K 收进来", { kind: "patrol" })).toEqual([]);
|
|
});
|
|
|
|
it("载入的 Skill 全文进「Skill」层,其余只列索引;追加消息用 Pi 的 skill 块附在消息前", () => {
|
|
const loaded = selectSkills(skills, "检查站点、下载器和媒体服务器是否正常", { kind: "chat" });
|
|
const p = buildLayeredPrompt({
|
|
kind: "chat",
|
|
role: "媒体库自动化智能体",
|
|
goal: "检查站点、下载器和媒体服务器是否正常",
|
|
skills: { loaded, available: skills },
|
|
});
|
|
expect(p).toContain("### Skill「巡检」");
|
|
expect(p).toContain("#### 汇报模板");
|
|
expect(p).toContain("- 收录一部作品:");
|
|
expect(p.match(/^## /gm)?.length).toBeGreaterThan(2);
|
|
const msg = withSkillInvocation(loaded, "再看看订阅");
|
|
expect(msg).toMatch(/^<skill name="patrol" location="skills\/patrol\/SKILL.md">/);
|
|
expect(msg.endsWith("再看看订阅")).toBe(true);
|
|
});
|
|
});
|