import type { FakeScript, FakeToolResult } from "@umt/agent"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts"; import { Browser } from "../auth-settings/harness.ts"; import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts"; // E12 Agent 交互自动化优先(AG-2): // - 计划事件顺序:agent.plan 先出步骤清单(plan_updated rev1)再执行,执行中更新,revision 递增; // - 方案选择:agent.propose_options 登记待处理事项并挂起会话(status=waiting、await_wake 已登记,不占 worker); // 用户选定后作为工具结果续跑;都不要 → rejected + 原因;超时按设置自动选推荐项; // - 先看方案再执行:选定方案前执行类能力被服务端拒绝;旧客户端 mode=plan_only 按它处理; // - 内联审批(控制台管理员会话):download.submit 没有持续授权 → 步骤处出审批卡并挂起;批准后重放执行、审批 consumed; // 拒绝 → 调用以拒绝理由失败,关联待处理事项写结果与建议。 const hasPg = await pgAvailable(); // biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON type Any = any; const ZERO = "00000000-0000-4000-8000-000000000000"; const PASSWORD = "correct horse battery"; const lastOk = (results: FakeToolResult[], cap: string): Any => [...results].reverse().find((r) => r.capabilityId === cap && !r.isError)?.json; const last = (results: FakeToolResult[], cap: string) => [...results].reverse().find((r) => r.capabilityId === cap); const OPTIONS_GOAL = "E12 先看方案:沙丘2 收哪个版本"; const NONE_GOAL = "E12 都不要:沙丘2 收哪个版本"; const TIMEOUT_GOAL = "E12 超时:沙丘2 收哪个版本"; const OPTIONS = [ { id: "a", title: "REMUX", summary: "画质最好", risks: ["体积大"], estimatedCost: "78 GB" }, { id: "b", title: "WEB-DL", summary: "体积小", risks: [], estimatedCost: "24 GB" }, ]; const optionsScript = (goal: string): FakeScript => ({ name: `e12-options-${goal}`, match: goal, steps: [ { text: "先列步骤。", toolCalls: [ { capability: "agent.plan", input: { steps: [ { id: "s1", title: "调查候选", status: "running" }, { id: "s2", title: "请你选方案" }, { id: "s3", title: "按方案执行" }, ], }, }, ], }, // 先看方案:选定前试图直接提交 → 服务端拒绝 { toolCalls: [{ capability: "download.submit", input: { planId: ZERO } }] }, { toolCalls: [ { capability: "agent.plan", input: { steps: [ { id: "s1", status: "done" }, { id: "s2", status: "running" }, ], }, }, ], }, { toolCalls: [ { capability: "agent.propose_options", input: { question: "收哪个版本?", options: OPTIONS, recommendedId: "a" }, }, ], }, { toolCalls: [ { capability: "agent.plan", input: { steps: [ { id: "s2", status: "done" }, { id: "s3", status: "done" }, ], }, }, ], }, ], finalText: (r) => { const o = lastOk(r, "agent.propose_options"); return `方案结果:${o?.outcome}/${o?.choice ?? "none"}/${o?.by}/${o?.reason ?? ""}`; }, }); // 审批:两个目标各用一个版本目标(拒绝与批准互不影响);ID 在 beforeAll 里填 const T: Record<"approve" | "reject", { slotId: string; releaseId: string }> = { approve: { slotId: ZERO, releaseId: ZERO }, reject: { slotId: ZERO, releaseId: ZERO }, }; const APPROVE_GOAL = "E12 审批通过:提交沙丘2 1080p"; const REJECT_GOAL = "E12 审批拒绝:提交沙丘2 1080p"; const approvalScript = (goal: string, key: "approve" | "reject"): FakeScript => ({ name: `e12-approval-${key}`, match: goal, steps: [ { toolCalls: [ { capability: "agent.plan", input: { steps: [ { id: "s1", title: "生成下载计划", status: "running" }, { id: "s2", title: "提交下载" }, ], }, }, ], }, { toolCalls: [ { capability: "download.plan", input: () => ({ slotId: T[key].slotId, sourceReleaseId: T[key].releaseId }) }, ], }, { toolCalls: [ { capability: "agent.plan", input: { steps: [ { id: "s1", status: "done" }, { id: "s2", status: "running" }, ], }, }, ], }, { toolCalls: [ { capability: "download.submit", input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }), }, ], }, ], finalText: (r) => { const s = last(r, "download.submit"); return s && !s.isError ? `已提交:${(s.json as Any)?.taskId}` : `未提交:${s?.text ?? "无结果"}`; }, }); describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联审批", () => { let e: E2E; let admin: Browser; beforeAll(async () => { e = await createE2E("e12", { fakeScripts: [ optionsScript(OPTIONS_GOAL), optionsScript(NONE_GOAL), optionsScript(TIMEOUT_GOAL), approvalScript(APPROVE_GOAL, "approve"), approvalScript(REJECT_GOAL, "reject"), ], }); const base = await seedBase(e); const a = await seedMovieTarget(e, base.siteId); T.approve = { slotId: a.slotId, releaseId: a.r1080 }; const site2 = await e.db .insertInto("site") .values({ key: "e12-second", name: "第二站", adapter: "nexusphp", base_url: "https://second.example/", rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), }) .returning("id") .executeTakeFirstOrThrow(); const b = await seedMovieTarget(e, site2.id); T.reject = { slotId: b.slotId, releaseId: b.r1080 }; // 控制台管理员会话(审批只接受它;它发起的会话才出会话内审批卡) const token = await ensureSetupToken(e.booted.ctx); admin = new Browser(e.app); const r = await admin.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD }); expect(r.status).toBe(200); }, 120_000); afterAll(async () => { await e?.close(); }, 60_000); const waitStatus = (runId: string, statuses: string[], ms = 60_000) => waitFor( async () => { const r = await runRow(e, runId); return statuses.includes(r.status) ? r : null; }, ms, `run ${statuses.join("/")}`, ); const waitEvent = (runId: string, type: string, ms = 60_000) => waitFor(async () => (await runEvents(e, runId)).find((x) => x.type === type), ms, `事件 ${type}`); it("方案选择:步骤清单先行、先看方案时执行被拒、挂起等待、选定后续跑", async () => { const created = await e.api("POST", "/agent/runs", { goal: OPTIONS_GOAL, proposeFirst: true }); expect(created.status).toBe(201); expect(created.body.data.scope).toMatchObject({ proposeFirst: true }); expect(created.body.data.mode).toBeUndefined(); const runId = created.body.data.id as string; const proposed = await waitEvent(runId, "options_proposed"); const p = proposed.payload as Any; expect(p).toMatchObject({ question: "收哪个版本?", recommendedId: "a", stepId: "s2", timeoutAction: "recommended", }); expect(p.options.map((o: Any) => o.id)).toEqual(["a", "b"]); // 挂起:会话等待中,唤醒信号已登记(runAgent 在 waitForFact 里,不占 worker、不调用模型) const waiting = await waitFor( async () => { const r = await runRow(e, runId); return r.status === "waiting" && r.await_wake ? r : null; }, 30_000, "挂起等待方案", ); expect(waiting.summary).toBe("等待选择方案"); const decision = await e.db .selectFrom("decision") .selectAll() .where("id", "=", p.decisionId) .executeTakeFirstOrThrow(); expect(decision).toMatchObject({ status: "awaiting_user", agent_run_id: runId, kind: "other" }); // 先看方案:选定前的 download.submit 被服务端拒绝 const events = await runEvents(e, runId); const submitEnd = events.find( (x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "download.submit", ); expect(submitEnd?.payload).toMatchObject({ isError: true }); expect(JSON.stringify(submitEnd?.payload)).toContain("先看方案"); // 选不存在的方案 → 422;选 a → 续跑 expect( (await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "zzz" })).status, ).toBe(422); const chosen = await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" }); expect(chosen.status).toBe(200); // 重复作答 → 409 expect( (await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "b" })).status, ).toBe(409); const done = await waitStatus(runId, ["succeeded", "failed"]); expect(done.status).toBe("succeeded"); expect(done.summary).toContain("方案结果:chosen/a/user/"); expect(done.await_wake).toBeNull(); const all = await runEvents(e, runId); const resolved = all.find((x) => x.type === "options_resolved"); expect(resolved?.payload).toMatchObject({ outcome: "chosen", choice: "a", by: "user", decisionId: p.decisionId }); // 计划事件顺序:第一版步骤清单早于第一个业务工具调用;版本递增;最后全部完成 const plans = all.filter((x) => x.type === "plan_updated"); expect(plans.map((x) => (x.payload as Any).revision)).toEqual([1, 2, 3]); const firstBiz = all.find( (x) => x.type === "tool_call_start" && !String((x.payload as Any).capabilityId).startsWith("agent."), ); expect(Number(plans[0]!.seq)).toBeLessThan(Number(firstBiz!.seq)); expect(Number(proposed.seq)).toBeLessThan(Number(resolved!.seq)); expect((plans.at(-1)!.payload as Any).steps.map((s: Any) => `${s.id}:${s.status}`)).toEqual([ "s1:done", "s2:done", "s3:done", ]); const d2 = await e.db.selectFrom("decision").selectAll().where("id", "=", p.decisionId).executeTakeFirstOrThrow(); expect(d2.status).toBe("resolved"); expect(d2.resolution).toMatchObject({ optionKey: "a", outcome: "chosen" }); }, 120_000); it("方案都不要:带原因退回,模型拿到 rejected 与原因", async () => { const runId = (await e.api("POST", "/agent/runs", { goal: NONE_GOAL })).body.data.id as string; const p = (await waitEvent(runId, "options_proposed")).payload as Any; await waitStatus(runId, ["waiting"], 30_000); // 都不要必须写原因 expect( (await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: null })).status, ).toBe(422); const r = await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: null, reason: "只要 WEB-DL 以外的", }); expect(r.status).toBe(200); const done = await waitStatus(runId, ["succeeded", "failed"]); expect(done.summary).toContain("方案结果:rejected/none/user/只要 WEB-DL 以外的"); const d = await e.db .selectFrom("decision") .select("status") .where("id", "=", p.decisionId) .executeTakeFirstOrThrow(); expect(d.status).toBe("dismissed"); }, 120_000); it("方案超时:按设置自动选推荐项", async () => { await e.settings({ agent: { optionsTimeoutSec: 5, optionsTimeoutAction: "recommended" } }); const runId = (await e.api("POST", "/agent/runs", { goal: TIMEOUT_GOAL })).body.data.id as string; const done = await waitStatus(runId, ["succeeded", "failed"], 90_000); expect(done.summary).toContain("方案结果:chosen/a/timeout/"); const resolved = (await runEvents(e, runId)).find((x) => x.type === "options_resolved"); expect(resolved?.payload).toMatchObject({ outcome: "chosen", choice: "a", by: "timeout" }); await e.settings({ agent: { optionsTimeoutSec: 1800 } }); }, 120_000); it("旧客户端 mode=plan_only:按先看方案再执行处理", async () => { const r = await e.api("POST", "/agent/runs", { goal: "E12 旧客户端只给方案", mode: "plan_only" }); expect(r.status).toBe(201); expect(r.body.data.scope).toMatchObject({ proposeFirst: true }); const row = await runRow(e, r.body.data.id); expect(row.mode).toBe("execute"); await e.api("POST", `/agent/runs/${r.body.data.id}/abort`, { reason: "测试" }); }); it("内联审批:管理员会话发起,步骤处出审批卡;批准后重放执行,审批 consumed", async () => { const res = await admin.req("POST", "/agent/runs", { goal: APPROVE_GOAL }); expect(res.status).toBe(201); const run = ((await res.json()) as Any).data; expect(run.scope).toMatchObject({ interactive: true }); const runId = run.id as string; const req = (await waitEvent(runId, "approval_requested")).payload as Any; expect(req).toMatchObject({ capabilityId: "download.submit", planType: "acquisition_plan", riskLevel: "high", stepId: "s2", }); expect(req.summary).toContain("沙丘2"); await waitFor( async () => { const r = await runRow(e, runId); return r.status === "waiting" && r.summary === "等待审批" && r.await_wake ? r : null; }, 30_000, "挂起等待审批", ); const approval = await e.db .selectFrom("approval") .selectAll() .where("id", "=", req.approvalId) .executeTakeFirstOrThrow(); expect(approval).toMatchObject({ status: "pending", plan_id: req.planId, payload_hash: req.payloadHash }); expect(approval.decision_id).not.toBeNull(); // API Key(开发密钥)不能审批:只接受控制台管理员会话 expect( (await e.api("PATCH", `/approvals/${req.approvalId}`, { status: "approved", payloadHash: req.payloadHash })) .status, ).toBe(403); const ok = await admin.req("PATCH", `/approvals/${req.approvalId}`, { status: "approved", payloadHash: req.payloadHash, }); expect(ok.status).toBe(200); // 提交后会话等下载 / 入库落定(waiting);模型本轮的汇报在 run_completed 里 const completed = (await waitEvent(runId, "run_completed")).payload as Any; expect(completed.finalText).toMatch(/^已提交:[0-9a-f-]{36}$/); const events = await runEvents(e, runId); expect(events.find((x) => x.type === "approval_resolved")?.payload).toMatchObject({ approvalId: req.approvalId, status: "approved", }); const submitEnd = events.filter( (x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "download.submit", ); expect(submitEnd.at(-1)?.payload).toMatchObject({ isError: false, recovery: true }); const a2 = await e.db.selectFrom("approval").selectAll().where("id", "=", req.approvalId).executeTakeFirstOrThrow(); expect(a2.status).toBe("consumed"); const inv = await e.db .selectFrom("capability_invocation") .selectAll() .where("approval_id", "=", req.approvalId) .executeTakeFirstOrThrow(); expect(inv).toMatchObject({ capability_id: "download.submit", status: "succeeded" }); const d = await e.db .selectFrom("decision") .selectAll() .where("id", "=", approval.decision_id!) .executeTakeFirstOrThrow(); expect(d.status).toBe("resolved"); }, 150_000); it("内联审批:拒绝后调用以理由失败,待处理事项写结果与建议", async () => { const res = await admin.req("POST", "/agent/runs", { goal: REJECT_GOAL }); const runId = ((await res.json()) as Any).data.id as string; const req = (await waitEvent(runId, "approval_requested")).payload as Any; await waitStatus(runId, ["waiting"], 30_000); const rej = await admin.req("PATCH", `/approvals/${req.approvalId}`, { status: "rejected", payloadHash: req.payloadHash, reason: "先别下,等 REMUX", }); expect(rej.status).toBe(200); const completed = (await waitEvent(runId, "run_completed")).payload as Any; expect(completed.finalText).toContain("未提交"); expect(completed.finalText).toContain("管理员拒绝了审批:先别下,等 REMUX"); const events = await runEvents(e, runId); expect(events.find((x) => x.type === "approval_resolved")?.payload).toMatchObject({ status: "rejected", reason: "先别下,等 REMUX", }); const a = await e.db.selectFrom("approval").selectAll().where("id", "=", req.approvalId).executeTakeFirstOrThrow(); expect(a.status).toBe("rejected"); const d = await e.db.selectFrom("decision").selectAll().where("id", "=", a.decision_id!).executeTakeFirstOrThrow(); expect(d.status).toBe("dismissed"); expect(d.resolution).toMatchObject({ approval: "rejected", reason: "先别下,等 REMUX", suggestion: "先别下,等 REMUX", }); // 没有执行:没有这份审批的成功调用 expect( await e.db.selectFrom("capability_invocation").select("id").where("approval_id", "=", req.approvalId).execute(), ).toHaveLength(0); }, 150_000); });