Files
UHD_Media_Tool/apps/server/test/e2e/e2-agent-delegate.test.ts
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

193 lines
7.8 KiB
TypeScript

import { CAPABILITY_IDS } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { delegateScript } from "./fixtures/scripts.ts";
import {
completeDownload,
createE2E,
type E2E,
pgAvailable,
runEvents,
runRow,
seedBase,
seedMovieTarget,
waitFor,
} from "./harness.ts";
// E2 Agent 委托闭环:fake 模型按"把《沙丘2》1080p 收进来"依次调用
// inventory.query → release.search → download.plan → download.submit → task.inspect。
// - 没有持续授权时 download.submit 被服务端拒绝(模型文本"用户已同意"无效),SSE 上是 denied;
// - 发了 automation_grant 后同一目标成功提交;模型汇报"已入库",但 run 的汇报来自整理记录:
// 下载完成前是 waiting(已提交≠已入库),核验入库后才 succeeded 并带整理记录 ID。
const hasPg = await pgAvailable();
const GOAL = "把《沙丘2》1080p 收进来";
describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权/任务)", () => {
let e: E2E;
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
beforeAll(async () => {
e = await createE2E("e2", { fakeScripts: [delegateScript] });
const base = await seedBase(e);
target = await seedMovieTarget(e, base.siteId);
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
const createRun = async () => {
const r = await e.api("POST", "/agent/runs", { goal: GOAL });
expect(r.status).toBe(201);
expect(r.body.data).toMatchObject({ status: "queued", trigger: "user" });
// 运行模式已取消(AG-2):API 不再返回 mode;API Key / 开发密钥发起的会话不出会话内审批卡
expect(r.body.data.mode).toBeUndefined();
expect(r.body.data.scope).not.toHaveProperty("interactive");
return r.body.data.id as string;
};
it("启动断言:合同中的能力全部注册,整理兜底不可重放,并在能力目录里可见", async () => {
expect(
e.booted.ctx.capabilities
.list()
.map((c) => c.descriptor.id)
.sort(),
).toEqual([...CAPABILITY_IDS].sort());
expect(
e.booted.ctx.capabilities.list().find((c) => c.descriptor.id === "metadata.resolve_organize")?.descriptor,
).toMatchObject({ kind: "execute", replay: "never" });
const r = await e.api("GET", "/capabilities");
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
e.booted.ctx.capabilities
.list()
.map((c) => c.descriptor.id)
.sort(),
);
});
it("没有授权:download.submit 被拒(模型声称已同意无效),不产生下载", async () => {
const seen: { type: string; payload: { phase?: string; capabilityId?: string } }[] = [];
const off = e.bus.subscribe((ev) => {
if (ev.type.startsWith("agent.")) seen.push(ev as never);
});
const runId = await createRun();
const run = await waitFor(
async () => {
const r = await runRow(e, runId);
return ["succeeded", "failed", "waiting"].includes(r.status) ? r : null;
},
60_000,
"run 结束",
);
off();
expect(run.status).toBe("succeeded");
const events = await runEvents(e, runId);
const submitEnd = events.find(
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
);
expect(submitEnd?.payload).toMatchObject({ isError: true });
expect(JSON.stringify(submitEnd?.payload)).toContain("unauthorized");
// SSE:未授权的调用标为 denied
expect(
seen.some(
(x) =>
x.type === "agent.tool_call" && x.payload.capabilityId === "download.submit" && x.payload.phase === "denied",
),
).toBe(true);
// 审计:capability.denied
const denied = await e.db
.selectFrom("audit_event")
.selectAll()
.where("action", "=", "capability.denied")
.where("target_id", "=", "download.submit")
.execute();
expect(denied.length).toBeGreaterThanOrEqual(1);
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
// 草案计划存在,但没有预留 Slot
const plans = await e.db.selectFrom("acquisition_plan").selectAll().execute();
expect(plans.map((p) => p.status)).toEqual(["draft"]);
const slot = await e.db
.selectFrom("acquisition_slot")
.select("status")
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("empty");
// 汇报不采信模型文本:没有可核查的业务变更
expect(run.summary).toContain("没有提交下载");
expect(run.summary).toContain("模型汇报(未经核查)");
}, 90_000);
it("有持续授权:提交成功 → 已提交≠已入库(waiting)→ 下载完成、核验入库后 succeeded,汇报来自整理记录", async () => {
const g = await e.api("POST", "/automation-grants", {
action: "download.submit",
scope: { mediaEntityIds: [target.entityId] },
reason: "e2e 授权",
});
expect(g.status).toBe(201);
const runId = await createRun();
// 模型已经说完"已入库",但业务上只是已提交:run 进入 waiting
const waiting = await waitFor(
async () => {
const r = await runRow(e, runId);
return r.status === "waiting" ? r : null;
},
60_000,
"run waiting",
);
expect(waiting.summary).toContain("已提交≠已入库");
const events = await runEvents(e, runId);
const completed = events.find((x) => x.type === "run_completed");
expect((completed!.payload as { finalText: string }).finalText).toContain("已入库");
const calls = events
.filter((x) => x.type === "tool_call_start")
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
expect(calls).toEqual(["inventory.query", "release.search", "download.plan", "download.submit", "task.inspect"]);
const submitEnd = events.find(
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
);
expect(submitEnd?.payload).toMatchObject({ isError: false });
// 账本:download.submit 记在本 run 名下,带授权 ID
const inv = await e.db
.selectFrom("capability_invocation")
.selectAll()
.where("agent_run_id", "=", runId)
.where("capability_id", "=", "download.submit")
.executeTakeFirstOrThrow();
expect(inv).toMatchObject({ status: "succeeded", grant_id: g.body.data.id });
// 草案被转为预留并提交
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const done = await waitFor(
async () => {
const r = await runRow(e, runId);
return r.status === "succeeded" ? r : null;
},
90_000,
"run succeeded",
);
const rec = await e.db
.selectFrom("organization_record")
.selectAll()
.where("status", "=", "succeeded")
.executeTakeFirstOrThrow();
expect(done.summary).toContain("已入库 1 个");
expect(done.summary).toContain(rec.id);
const detail = await e.api("GET", `/agent/runs/${runId}?afterSeq=-1&limit=500`);
expect(detail.body.data.report).toMatchObject({ state: "completed", imported: 1, submitted: 1 });
expect(detail.body.data.report.organization[0]).toMatchObject({ recordId: rec.id, status: "succeeded" });
// 断线补读:afterSeq 之后的事件
const tail = await e.api("GET", `/agent/runs/${runId}?afterSeq=3&limit=2`);
expect(tail.body.data.events.map((x: { seq: number }) => x.seq)).toEqual([4, 5]);
expect(tail.body.data.nextAfterSeq).toBe(5);
// 入库成功:Slot 满足
const slot = await e.db
.selectFrom("acquisition_slot")
.select("status")
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("satisfied");
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
}, 180_000);
});