Files
UHD_Media_Tool/apps/server/test/e2e/e12-interaction.test.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

432 lines
17 KiB
TypeScript

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("等待选择方案");
// AG-3:挂起时没有 run_completed,已用累计照样写回(等待期间界面显示已用 token / 费用,而不是"未报告")
expect(Number((waiting.usage as Any).totalTokens ?? 0)).toBeGreaterThan(0);
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);
});