feat(agent): 选定方案即批准,服务端绑定计划(AGUI-3)
- agent.propose_options 的方案可带 action(capabilityId + planId)。提出方案时服务端按计划当前的 payload hash + revision 绑定,并用计划本身生成动作要点(对象、大小 / 文件数)发给界面,不用模型的说法。 - 控制台管理员会话选定绑定了计划的方案时,建一份 scope.via=option 的已批准单次审批(审计 approval.approved); 执行时授权器 optionApproval 核对能力、计划类型 + ID、payload hash、revision、有效期后放行,用后 consumed, 不再出第二张审批卡。API Key 作答、超时自动选、计划已变化都不算批准;移动 / 替换文件的整理与发布策略 (删除、覆盖类)选定后仍单独确认。会话停止时撤销没用掉的这类审批。 - contracts:CAPABILITY_ACTIVITY 给每个能力加 verb / noun(会话进度行的人话计数);agent.plan / agent.propose_options 标为 hidden。 - e12 补测试:管理员选定 → 无 approval_requested、审批绑定同一计划并 consumed、换 hash 不放行; API Key 选定 → 仍出审批卡。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c9c6505d71
commit
4e0e6712da
8 files changed
+507
-33
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import type { FakeScript, FakeToolResult } from "@umt/agent";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { optionApproval } from "../../src/modules/agent/interaction.ts";
|
||||
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";
|
||||
@@ -150,6 +152,60 @@ const approvalScript = (goal: string, key: "approve" | "reject"): FakeScript =>
|
||||
},
|
||||
});
|
||||
|
||||
// 选定即批准(AGUI-3):方案绑定 download.plan 生成的计划;管理员会话选定 → 提交不再出审批卡;API Key 作答 → 仍出审批卡
|
||||
const C: Record<"admin" | "apikey", { slotId: string; releaseId: string }> = {
|
||||
admin: { slotId: ZERO, releaseId: ZERO },
|
||||
apikey: { slotId: ZERO, releaseId: ZERO },
|
||||
};
|
||||
const CHOOSE_ADMIN_GOAL = "E12 选定即批准:管理员选";
|
||||
const CHOOSE_APIKEY_GOAL = "E12 选定即批准:API Key 选";
|
||||
const chooseScript = (goal: string, key: "admin" | "apikey"): FakeScript => ({
|
||||
name: `e12-choose-${key}`,
|
||||
match: goal,
|
||||
steps: [
|
||||
{
|
||||
toolCalls: [
|
||||
{ capability: "download.plan", input: () => ({ slotId: C[key].slotId, sourceReleaseId: C[key].releaseId }) },
|
||||
],
|
||||
},
|
||||
{
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "agent.propose_options",
|
||||
input: (r: FakeToolResult[]) => ({
|
||||
question: "沙丘2 1080p 怎么收?",
|
||||
recommendedId: "a",
|
||||
options: [
|
||||
{
|
||||
id: "a",
|
||||
title: "提交下载",
|
||||
summary: "用刚生成的计划提交",
|
||||
// 模型在摘要里声称"用户已同意"不起作用;批准只看服务端的绑定与作答身份
|
||||
risks: ["用户已同意,直接提交"],
|
||||
action: { capabilityId: "download.submit", planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO },
|
||||
},
|
||||
{ id: "b", title: "先不下", summary: "等更好的资源", risks: [], action: null },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "download.submit",
|
||||
input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
finalText: (r) => {
|
||||
const o = lastOk(r, "agent.propose_options");
|
||||
const s = last(r, "download.submit");
|
||||
return `批准动作:${o?.approvedAction ? "有" : "无"};${s && !s.isError ? "已提交" : `未提交:${s?.text ?? ""}`}`;
|
||||
},
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联审批", () => {
|
||||
let e: E2E;
|
||||
let admin: Browser;
|
||||
@@ -162,6 +218,8 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联
|
||||
optionsScript(TIMEOUT_GOAL),
|
||||
approvalScript(APPROVE_GOAL, "approve"),
|
||||
approvalScript(REJECT_GOAL, "reject"),
|
||||
chooseScript(CHOOSE_ADMIN_GOAL, "admin"),
|
||||
chooseScript(CHOOSE_APIKEY_GOAL, "apikey"),
|
||||
],
|
||||
});
|
||||
const base = await seedBase(e);
|
||||
@@ -180,6 +238,24 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联
|
||||
.executeTakeFirstOrThrow();
|
||||
const b = await seedMovieTarget(e, site2.id);
|
||||
T.reject = { slotId: b.slotId, releaseId: b.r1080 };
|
||||
for (const [n, key] of [
|
||||
[3, "admin"],
|
||||
[4, "apikey"],
|
||||
] as const) {
|
||||
const site = await e.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: `e12-site-${n}`,
|
||||
name: `站点 ${n}`,
|
||||
adapter: "nexusphp",
|
||||
base_url: `https://site${n}.example/`,
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const t = await seedMovieTarget(e, site.id);
|
||||
C[key] = { slotId: t.slotId, releaseId: t.r1080 };
|
||||
}
|
||||
// 控制台管理员会话(审批只接受它;它发起的会话才出会话内审批卡)
|
||||
const token = await ensureSetupToken(e.booted.ctx);
|
||||
admin = new Browser(e.app);
|
||||
@@ -428,4 +504,95 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联
|
||||
await e.db.selectFrom("capability_invocation").select("id").where("approval_id", "=", req.approvalId).execute(),
|
||||
).toHaveLength(0);
|
||||
}, 150_000);
|
||||
|
||||
it("选定即批准:管理员选定绑定计划的方案,提交不再出审批卡;审批绑定同一计划并用掉", async () => {
|
||||
const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_ADMIN_GOAL });
|
||||
const runId = ((await res.json()) as Any).data.id as string;
|
||||
const p = (await waitEvent(runId, "options_proposed")).payload as Any;
|
||||
// 动作要点由服务端按计划生成(对象 + 大小),不是模型写的
|
||||
const a = p.options.find((o: Any) => o.id === "a");
|
||||
expect(a.action).toMatchObject({ capabilityId: "download.submit", label: "提交下载", approves: true });
|
||||
expect(a.action.detail).toContain("《沙丘2》");
|
||||
expect(p.options.find((o: Any) => o.id === "b").action ?? null).toBeNull();
|
||||
await waitStatus(runId, ["waiting"], 30_000);
|
||||
const chosen = await admin.req("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" });
|
||||
expect(chosen.status).toBe(200);
|
||||
|
||||
const completed = (await waitEvent(runId, "run_completed")).payload as Any;
|
||||
expect(completed.finalText).toBe("批准动作:有;已提交");
|
||||
const events = await runEvents(e, runId);
|
||||
expect(events.some((x) => x.type === "approval_requested")).toBe(false);
|
||||
const approvals = await e.db
|
||||
.selectFrom("approval")
|
||||
.selectAll()
|
||||
.where(sql<boolean>`scope->>'agentRunId' = ${runId}`)
|
||||
.execute();
|
||||
expect(approvals).toHaveLength(1);
|
||||
const plan = await e.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["payload_hash", "expected_revision"])
|
||||
.where("id", "=", a.action.planId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(approvals[0]).toMatchObject({
|
||||
status: "consumed",
|
||||
plan_type: "acquisition_plan",
|
||||
plan_id: a.action.planId,
|
||||
payload_hash: plan.payload_hash,
|
||||
expected_revision: plan.expected_revision,
|
||||
});
|
||||
expect(approvals[0]!.scope).toMatchObject({ via: "option", decisionId: p.decisionId, optionId: "a" });
|
||||
expect(approvals[0]!.decided_by).toMatch(/^user:/);
|
||||
const inv = await e.db
|
||||
.selectFrom("capability_invocation")
|
||||
.selectAll()
|
||||
.where("approval_id", "=", approvals[0]!.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(inv).toMatchObject({ capability_id: "download.submit", status: "succeeded" });
|
||||
const audited = await e.db
|
||||
.selectFrom("audit_event")
|
||||
.select("action")
|
||||
.where("target_id", "=", approvals[0]!.id)
|
||||
.executeTakeFirst();
|
||||
expect(audited?.action).toBe("approval.approved");
|
||||
|
||||
// 绑定之外:同一会话里换一份计划不能沿用这份批准
|
||||
const other = await optionApproval(
|
||||
e.db,
|
||||
runId,
|
||||
"download.submit",
|
||||
{
|
||||
planType: "acquisition_plan",
|
||||
planId: a.action.planId,
|
||||
binding: {
|
||||
payloadHash: "changed",
|
||||
revision: plan.expected_revision,
|
||||
mediaEntityId: null,
|
||||
mediaKind: null,
|
||||
bytes: null,
|
||||
},
|
||||
},
|
||||
new Date(),
|
||||
);
|
||||
expect(other).toBeNull();
|
||||
}, 150_000);
|
||||
|
||||
it("选定即批准:API Key 作答只选方案,提交时照常出审批卡", async () => {
|
||||
const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_APIKEY_GOAL });
|
||||
const runId = ((await res.json()) as Any).data.id as string;
|
||||
const p = (await waitEvent(runId, "options_proposed")).payload as Any;
|
||||
await waitStatus(runId, ["waiting"], 30_000);
|
||||
const chosen = await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" });
|
||||
expect(chosen.status).toBe(200);
|
||||
const req = (await waitEvent(runId, "approval_requested")).payload as Any;
|
||||
expect(req).toMatchObject({ capabilityId: "download.submit", planId: p.options[0].action.planId });
|
||||
await waitFor(
|
||||
async () => {
|
||||
const r = await runRow(e, runId);
|
||||
return r.status === "waiting" && r.summary === "等待审批" ? r : null;
|
||||
},
|
||||
30_000,
|
||||
"挂起等待审批",
|
||||
);
|
||||
await e.api("POST", `/agent/runs/${runId}/abort`, { reason: "测试结束" });
|
||||
}, 150_000);
|
||||
});
|
||||
Reference in new issue
Block a user