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>; 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); });