Files
UHD_Media_Tool/apps/server/test/e2e/agent-prompt-eval.test.ts
rootandClaude Opus 5.5 b61335eaf6 test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:54:04 +08:00

154 lines
6.9 KiB
TypeScript

import { type FakeScript, type FakeToolResult, lintReport } from "@umt/agent";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { RunPrompt } from "../../src/modules/agent/runner.ts";
import { type EvalCase, type EvalWorld, evalCases, evalScripts, seedEvalWorld } from "./fixtures/agent-eval.ts";
import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./harness.ts";
// E16 智能体提示评测(AG-5,fake 模型 + 真实能力 + 测试库数据):
// - 固定提示集(8 条,含反例"检查站点、下载器和媒体服务器是否正常"):按意图载入的 Skill、当前环境层的事实;
// - fake 模型按"合格模型"的路线调用能力:断言调用顺序、只读调用都成功、需要批准的被拒绝(不向用户要 ID:
// download.inspect 的 ID 来自 download.list);
// - 汇报格式约束:合格汇报(只用工具结果里的数据写)通过 lintReport(无 UUID / 原始字节 / 内部字段,≤8 行,结论先行)。
// 真实模型的前后对比见 agent-prompt-eval.real.test.ts 与 docs/design/agent-prompt-eval.md。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("E16 智能体提示评测(fake 模型)", () => {
const scripts: FakeScript[] = [];
const prompts = new Map<string, RunPrompt>();
const done = new Map<string, { results: FakeToolResult[]; text: string }>();
let e: E2E;
let world: EvalWorld;
let cases: EvalCase[];
beforeAll(async () => {
e = await createE2E("e16", {
fakeScripts: scripts,
agent: {
systemPrompt: (p, run) => {
prompts.set(run.goal, p);
return p.systemPrompt;
},
},
});
world = await seedEvalWorld(e);
cases = evalCases(world);
scripts.push(...evalScripts(cases, (id, results, text) => done.set(id, { results, text })));
// 等下载同步把 mock qB 的状态写回(下载器连通状态、任务状态)
await waitFor(
async () =>
(await e.db.selectFrom("downloader_instance").select("health_status").executeTakeFirst())?.health_status ===
"healthy",
20_000,
"下载器同步",
);
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
const runToEnd = async (goal: string) => {
const r = await e.api("POST", "/agent/runs", { goal });
expect(r.status).toBe(201);
const runId = r.body.data.id as string;
const run = await waitFor(
async () => {
const x = await runRow(e, runId);
return ["succeeded", "failed", "waiting", "budget_exceeded", "cancelled"].includes(x.status) ? x : null;
},
60_000,
`run 结束:${goal}`,
);
return { runId, run };
};
it("固定提示集至少 8 条,含反例那条", () => {
expect(cases.length).toBeGreaterThanOrEqual(8);
expect(cases.map((c) => c.goal)).toContain("检查站点、下载器和媒体服务器是否正常");
});
it.each(["health", "acquire", "why", "subs", "failed", "storage", "decisions", "site-login"])(
"%s:Skill、环境层、调用顺序与汇报格式",
async (id) => {
const c = cases.find((x) => x.id === id)!;
const { runId, run } = await runToEnd(c.goal);
expect(["succeeded", "waiting"], `${run.status}: ${run.summary}`).toContain(run.status);
// 系统提示:按意图载入的 Skill + 当前环境
const p = prompts.get(c.goal)!;
expect(p.kind).toBe("chat");
expect(p.skills[0]?.name ?? null).toBe(c.skill);
expect(p.systemPrompt).toContain("下载器 1 个:「家里的 qB」qBittorrent");
expect(p.systemPrompt).toContain("媒体服务器:还没有接入");
expect(p.systemPrompt).toContain("「憨憨」PT 站,");
expect(p.systemPrompt).not.toMatch(/127\.0\.0\.1|hhanclub/);
// 调用顺序:按事件流里的 tool_call_start
const events = await runEvents(e, runId);
const calls = events
.filter((x) => x.type === "tool_call_start")
.map((x) => (x.payload as { capabilityId?: string }).capabilityId);
expect(calls).toEqual(c.calls);
// 巡检那条:需要批准的检测实际调用一次(界面才有审批卡 /「批准并重试」),汇报里写「没做:…,需要你批准」
if (id === "health") {
expect(calls.filter((x) => x === "site.health_check")).toHaveLength(1);
expect(done.get(id)!.text).toMatch(/没做:.*需要你批准/);
}
// 只读调用都成功;需要批准的被拒绝
const r = done.get(id)!;
for (const x of r.results) {
const denied = c.denied?.includes(x.capabilityId ?? "");
expect(x.isError, `${x.capabilityId}: ${x.text.slice(0, 200)}`).toBe(!!denied);
if (denied) expect(x.text).toMatch(/unauthorized|授权|批准/);
}
// 汇报格式
expect(lintReport(r.text), r.text).toEqual([]);
},
90_000,
);
it("download.list:人话大小 / 进度 / 相对时间,不含 hash 与原始字节数", async () => {
const r = done.get("storage")!.results.find((x) => x.capabilityId === "download.list")!;
expect(r.json).toMatchObject({
total: 1,
byStatus: [{ status: "imported", label: "已入库", count: 1 }],
items: [{ name: "Poor.Things.2023.2160p.WEB-DL", size: "18.6 GB", progress: "100%", completed: "12 天前" }],
});
expect(r.text).not.toMatch(/c3c3c3|20000000000/);
});
it("巡检会话固定载入「巡检」Skill,汇报层要求按巡检模板写", async () => {
const { buildRunPrompt, scopeFor } = await import("../../src/modules/agent/runner.ts");
const row = {
...(await e.db.selectFrom("agent_run").selectAll().limit(1).executeTakeFirstOrThrow()),
trigger: "schedule" as const,
goal: "巡检待处理(2026-09-30):有 1 条未关闭的待处理事项。",
mode: "plan_only" as const,
};
const scope = scopeFor("plan_only", ["decision.list", "downloader.list", "site.list"]);
const p = await buildRunPrompt({ ctx: e.booted.ctx, opts: {} }, row, scope, {
canSpawn: false,
maxConcurrent: 1,
maxDepth: 1,
});
expect(p.kind).toBe("patrol");
expect(p.skills[0]?.name).toBe("patrol");
expect(p.systemPrompt).toContain("### Skill「巡检」");
expect(p.systemPrompt).toContain("按 Skill「巡检」的汇报模板写");
expect(p.systemPrompt).toContain("本次只读");
});
it("追加消息换了意图:新命中的 Skill 用 Pi 的 skill 块附在消息前,已载入的不重复", async () => {
const { followUpWithSkills } = await import("../../src/modules/agent/runner.ts");
const p = prompts.get("检查站点、下载器和媒体服务器是否正常")!;
const copy = { ...p, skills: [...p.skills] };
const msg = followUpWithSkills(copy, "那订阅追更情况呢");
expect(msg).toMatch(/^<skill name="subscription-check"/);
expect(followUpWithSkills(copy, "订阅再看一遍")).toBe("订阅再看一遍");
expect(followUpWithSkills(copy, "站点正常吗")).toBe("站点正常吗");
});
});