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
@@ -3,11 +3,12 @@ import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent";
|
||||
import type { CapabilitySpec } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { SettingsReader } from "../types.ts";
|
||||
import { inlineApproval, proposeFirstBlock } from "./interaction.ts";
|
||||
import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts";
|
||||
import { exceedsAncestors } from "./subagents.ts";
|
||||
|
||||
// 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中
|
||||
// - 单次审批 approval:status=approved、绑定 plan 类型 + plan id + payload hash + revision、未过期(用后 consumed);或
|
||||
// - 选定即批准:管理员会话选定的方案绑定同一计划(AGUI-3,见 interaction.ts optionApproval);或
|
||||
// - 持续授权 automation_grant:action 覆盖本能力、active 未过期、绑定的策略版本仍是 published、
|
||||
// 对象范围(mediaEntityIds / mediaKinds)包含目标、预算(每日次数 / 字节)未用尽。
|
||||
// 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。
|
||||
@@ -33,7 +34,7 @@ export const GRANT_ACTIONS: Record<string, readonly string[]> = {
|
||||
const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]);
|
||||
|
||||
/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
|
||||
const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
|
||||
export const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
|
||||
"download.submit": { planType: "acquisition_plan", field: "planId" },
|
||||
"organize.execute": { planType: "organize_plan", field: "planId" },
|
||||
"policy.apply": { planType: "policy_revision", field: "policyRevisionId" },
|
||||
@@ -253,7 +254,13 @@ export function createAuthorizer(deps: {
|
||||
const covered = await coveringGrant(db, d.id, target, now());
|
||||
if ("grantId" in covered) return { allowed: true, grantId: covered.grantId };
|
||||
const reasons = covered.reasons;
|
||||
// 3) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话
|
||||
// 3) 选定即批准(AGUI-3):管理员会话选定的方案绑定了同一份计划(类型 + ID + payload hash + revision)
|
||||
if (ap && target && !approvalId && runId && (ctx.source === "agent" || ctx.source === "task")) {
|
||||
const planId = inp[ap.field] as string;
|
||||
const chosen = await optionApproval(db, runId, d.id, { planType: ap.planType, planId, binding: target }, now());
|
||||
if (chosen) return { allowed: true, approvalId: chosen.approvalId };
|
||||
}
|
||||
// 4) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话
|
||||
if (ap && target && !approvalId) {
|
||||
const planId = inp[ap.field] as string;
|
||||
const inline = await inlineApproval(db, d, { planType: ap.planType, planId, binding: target }, ctx, now());
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
redactString,
|
||||
} from "@umt/agent";
|
||||
import {
|
||||
type AgentOptionAction,
|
||||
type AgentOptionItem,
|
||||
type AgentOptionsTimeoutAction,
|
||||
type AgentOptionView,
|
||||
type AgentPlanStep,
|
||||
type AgentProposeOptionsOutput,
|
||||
CAPABILITY_CATALOG,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
} from "@umt/contracts";
|
||||
import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
import type { PlanBinding } from "./authorize.ts";
|
||||
import { APPROVAL_PLAN, type PlanBinding, planBinding } from "./authorize.ts";
|
||||
import {
|
||||
AwaitSuspendedError,
|
||||
type AwaitSuspension,
|
||||
@@ -189,15 +191,28 @@ async function closeDecision(
|
||||
|
||||
// ── 方案选择 ──
|
||||
|
||||
/** 方案绑定的计划(提出方案时按计划当前内容固定;选定即批准时照它建审批) */
|
||||
export interface OptionBinding {
|
||||
capabilityId: AgentOptionAction["capabilityId"];
|
||||
planType: string;
|
||||
planId: string;
|
||||
payloadHash: string;
|
||||
revision: number;
|
||||
/** false = 删除 / 覆盖类动作,选定不代表批准,执行时仍单独确认 */
|
||||
approves: boolean;
|
||||
}
|
||||
|
||||
interface OptionsEvidence {
|
||||
source: "agent.propose_options";
|
||||
runId: string;
|
||||
invocationId: string;
|
||||
question: string;
|
||||
options: AgentOptionItem[];
|
||||
options: AgentOptionView[];
|
||||
recommendedId: string | null;
|
||||
expiresAt: string;
|
||||
timeoutAction: AgentOptionsTimeoutAction;
|
||||
/** 方案 id → 绑定的计划 */
|
||||
bindings?: Record<string, OptionBinding>;
|
||||
}
|
||||
interface OptionsResolution {
|
||||
optionKey?: string | null;
|
||||
@@ -206,6 +221,8 @@ interface OptionsResolution {
|
||||
note?: string | null;
|
||||
by?: string;
|
||||
timeout?: boolean;
|
||||
/** 选定即批准时建的审批 */
|
||||
approvalId?: string | null;
|
||||
}
|
||||
|
||||
function optionsResult(
|
||||
@@ -217,15 +234,181 @@ function optionsResult(
|
||||
const choice = res.optionKey ?? null;
|
||||
const option = choice ? (ev.options.find((o) => o.id === choice) ?? null) : null;
|
||||
const outcome = res.outcome ?? (status === "resolved" && option ? "chosen" : "rejected");
|
||||
const chosen = !!option && outcome === "chosen";
|
||||
const binding = chosen && choice ? ev.bindings?.[choice] : undefined;
|
||||
return {
|
||||
outcome: option && outcome === "chosen" ? "chosen" : outcome === "stop" ? "stop" : "rejected",
|
||||
outcome: chosen ? "chosen" : outcome === "stop" ? "stop" : "rejected",
|
||||
choice: option ? choice : null,
|
||||
option,
|
||||
option: option
|
||||
? {
|
||||
...option,
|
||||
action: option.action ? { capabilityId: option.action.capabilityId, planId: option.action.planId } : null,
|
||||
}
|
||||
: null,
|
||||
reason: safe(res.reason ?? res.note ?? null, 1000) || null,
|
||||
by,
|
||||
approvedAction: binding && res.approvalId ? { capabilityId: binding.capabilityId, planId: binding.planId } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 选定即批准(AGUI-3) ──
|
||||
|
||||
/** 动作要点:对象 + 大小 / 数量(从计划本身取,不用模型的说法) */
|
||||
async function actionDetail(db: Kysely<DB>, planType: string, planId: string): Promise<string | null> {
|
||||
if (planType === "acquisition_plan") {
|
||||
const r = await db
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
|
||||
.select(["e.title as entity", "p.reserved_bytes"])
|
||||
.where("p.id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
if (!r) return null;
|
||||
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
|
||||
return safe(`《${r.entity}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200);
|
||||
}
|
||||
if (planType === "organize_plan") {
|
||||
const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst();
|
||||
const c = (r?.content ?? {}) as {
|
||||
request?: { action?: string };
|
||||
plan?: { organize?: { files?: { action?: string }[] } };
|
||||
};
|
||||
const n = (c.plan?.organize?.files ?? []).filter((f) => f.action === "transfer" || f.action === "replace").length;
|
||||
return `${n} 个文件 · ${c.request?.action === "move" ? "移动" : "复制"}`;
|
||||
}
|
||||
if (planType === "policy_revision") {
|
||||
const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst();
|
||||
return r ? `策略版本 r${r.revision}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定能不能当作批准:只有提交下载、只复制且不替换已有文件的整理。
|
||||
* 移动(删源文件)、替换已有文件、发布策略(覆盖当前策略)都要单独确认。
|
||||
*/
|
||||
async function actionApprovable(db: Kysely<DB>, capabilityId: string, planId: string): Promise<boolean> {
|
||||
if (capabilityId === "download.submit") return true;
|
||||
if (capabilityId === "organize.execute") {
|
||||
const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst();
|
||||
const c = (r?.content ?? {}) as {
|
||||
request?: { action?: string };
|
||||
plan?: { organize?: { files?: { action?: string }[] } };
|
||||
};
|
||||
return c.request?.action === "copy" && !(c.plan?.organize?.files ?? []).some((f) => f.action === "replace");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 提出方案时解析各方案的动作:计划必须存在;绑定计划当前的 payload hash + revision */
|
||||
export async function resolveOptionActions(
|
||||
db: Kysely<DB>,
|
||||
options: AgentOptionItem[],
|
||||
): Promise<{ views: AgentOptionView[]; bindings: Record<string, OptionBinding> }> {
|
||||
const views: AgentOptionView[] = [];
|
||||
const bindings: Record<string, OptionBinding> = {};
|
||||
for (const o of options) {
|
||||
if (!o.action) {
|
||||
views.push({ ...o, action: null });
|
||||
continue;
|
||||
}
|
||||
const ap = APPROVAL_PLAN[o.action.capabilityId]!;
|
||||
const b = await planBinding(db, ap.planType, o.action.planId);
|
||||
if (!b)
|
||||
throw new CapabilityError(
|
||||
"invalid_input",
|
||||
`方案 ${o.id} 的 action 指向的计划不存在:先生成计划,再把计划 ID 填进 action.planId`,
|
||||
);
|
||||
const approves = await actionApprovable(db, o.action.capabilityId, o.action.planId);
|
||||
bindings[o.id] = {
|
||||
capabilityId: o.action.capabilityId,
|
||||
planType: ap.planType,
|
||||
planId: o.action.planId,
|
||||
payloadHash: b.payloadHash,
|
||||
revision: b.revision,
|
||||
approves,
|
||||
};
|
||||
views.push({
|
||||
...o,
|
||||
action: {
|
||||
...o.action,
|
||||
label: CAPABILITY_CATALOG[o.action.capabilityId].title,
|
||||
detail: await actionDetail(db, ap.planType, o.action.planId),
|
||||
approves,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { views, bindings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员会话选定绑定了计划的方案:按提出方案时固定的绑定建一份已批准的单次审批。
|
||||
* 计划在这期间变了(hash / revision 不同)就不建,执行时照常出审批卡。
|
||||
*/
|
||||
async function approveChosenAction(
|
||||
ctx: ServerContext,
|
||||
runId: string,
|
||||
decisionId: string,
|
||||
optionId: string,
|
||||
b: OptionBinding,
|
||||
by: string,
|
||||
): Promise<string | null> {
|
||||
if (!b.approves) return null;
|
||||
const cur = await planBinding(ctx.db, b.planType, b.planId);
|
||||
if (!cur || cur.payloadHash !== b.payloadHash || cur.revision !== b.revision) return null;
|
||||
const s = await ctx.settings.get("agent");
|
||||
const scope = { agentRunId: runId, via: "option", decisionId, optionId, capabilityId: b.capabilityId };
|
||||
const a = await ctx.db
|
||||
.insertInto("approval")
|
||||
.values({
|
||||
plan_type: b.planType,
|
||||
plan_id: b.planId,
|
||||
payload_hash: b.payloadHash,
|
||||
expected_revision: b.revision,
|
||||
scope: JSON.stringify(scope),
|
||||
requested_by: `agent:${runId}`,
|
||||
status: "approved",
|
||||
decided_by: by,
|
||||
decided_at: new Date(),
|
||||
expires_at: new Date(Date.now() + s.approvalTimeoutSec * 1000),
|
||||
reason: "选定方案时一并批准",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return a.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行需审批的动作时:本会话(或祖先会话)里有随选定方案批准、绑定同一计划且未过期的审批就放行。
|
||||
* 核对能力、计划类型 + ID、payload hash、revision;不看模型参数里的任何"已同意"。
|
||||
*/
|
||||
export async function optionApproval(
|
||||
db: Kysely<DB>,
|
||||
runId: string,
|
||||
capabilityId: string,
|
||||
plan: { planType: string; planId: string; binding: PlanBinding },
|
||||
now: Date,
|
||||
): Promise<{ approvalId: string } | null> {
|
||||
const runIds = [runId, ...(await ancestors(db, runId)).map((r) => r.id)];
|
||||
const rows = await db
|
||||
.selectFrom("approval")
|
||||
.selectAll()
|
||||
.where("status", "=", "approved")
|
||||
.where("plan_type", "=", plan.planType)
|
||||
.where("plan_id", "=", plan.planId)
|
||||
.where(sql<boolean>`scope->>'via' = 'option'`)
|
||||
.where(sql<boolean>`scope->>'capabilityId' = ${capabilityId}`)
|
||||
.where(sql<boolean>`scope->>'agentRunId' in (${sql.join(runIds)})`)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute();
|
||||
for (const a of rows) {
|
||||
if (a.payload_hash !== plan.binding.payloadHash || a.expected_revision !== plan.binding.revision) continue;
|
||||
if (a.expires_at && a.expires_at.getTime() <= now.getTime()) continue;
|
||||
return { approvalId: a.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户回答方案选择(会话页或 API):选一个方案或都不要。写待处理事项后唤醒挂起的会话。
|
||||
* 返回 false = 事项不属于这个会话 / 已处理。
|
||||
@@ -235,15 +418,21 @@ export async function answerOptions(
|
||||
runId: string,
|
||||
input: { decisionId: string; optionId: string | null; reason?: string | undefined },
|
||||
by: string,
|
||||
): Promise<"ok" | "not_found" | "closed" | "bad_option"> {
|
||||
/** 控制台管理员会话作答:选定绑定了计划的方案即批准这份计划(API Key 作答只选方案,不批准) */
|
||||
opts: { approver?: boolean } = {},
|
||||
): Promise<{ status: "ok"; approvalId: string | null } | { status: "not_found" | "closed" | "bad_option" }> {
|
||||
const d = await ctx.db
|
||||
.selectFrom("decision")
|
||||
.select(["id", "status", "evidence", "agent_run_id", "dedupe_key"])
|
||||
.where("id", "=", input.decisionId)
|
||||
.executeTakeFirst();
|
||||
if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return "not_found";
|
||||
if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return { status: "not_found" };
|
||||
const ev = d.evidence as unknown as OptionsEvidence;
|
||||
if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return "bad_option";
|
||||
if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return { status: "bad_option" };
|
||||
if (!["open", "investigating", "awaiting_user"].includes(d.status)) return { status: "closed" };
|
||||
const binding = input.optionId ? ev.bindings?.[input.optionId] : undefined;
|
||||
const approvalId =
|
||||
binding && opts.approver ? await approveChosenAction(ctx, runId, d.id, input.optionId!, binding, by) : null;
|
||||
const closed = await closeDecision(
|
||||
ctx,
|
||||
d.id,
|
||||
@@ -253,12 +442,18 @@ export async function answerOptions(
|
||||
outcome: input.optionId ? "chosen" : "rejected",
|
||||
reason: input.reason ?? null,
|
||||
by,
|
||||
approvalId,
|
||||
},
|
||||
by,
|
||||
);
|
||||
if (!closed) return "closed";
|
||||
if (!closed) {
|
||||
// 并发作答:另一方先关了事项,撤回这份没有生效的审批
|
||||
if (approvalId)
|
||||
await ctx.db.updateTable("approval").set({ status: "revoked" }).where("id", "=", approvalId).execute();
|
||||
return { status: "closed" };
|
||||
}
|
||||
await wakeRun(ctx, runId, { decisionId: d.id });
|
||||
return "ok";
|
||||
return { status: "ok", approvalId };
|
||||
}
|
||||
|
||||
// ── 审批 ──
|
||||
@@ -508,6 +703,13 @@ export async function dismissPendingInteractions(ctx: ServerContext, runId: stri
|
||||
.where(sql<boolean>`scope->>'agentRunId' = ${runId}`)
|
||||
.where("status", "=", "pending")
|
||||
.execute();
|
||||
// 选定即批准的审批还没用掉:会话停了就撤销(只绑本会话,不留给别处)
|
||||
await ctx.db
|
||||
.updateTable("approval")
|
||||
.set({ status: "revoked", reason: "会话已停止" })
|
||||
.where(sql<boolean>`scope->>'agentRunId' = ${runId} and scope->>'via' = 'option'`)
|
||||
.where("status", "=", "approved")
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 先看方案再执行:用户选定方案前拒绝执行类能力。返回拒绝原因或 null */
|
||||
@@ -603,16 +805,6 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
const recommendedId = input.recommendedId && ids.includes(input.recommendedId) ? input.recommendedId : null;
|
||||
const s = await ctx.settings.get("agent");
|
||||
const now = Date.now();
|
||||
const evidence: OptionsEvidence = {
|
||||
source: "agent.propose_options",
|
||||
runId: run.id,
|
||||
invocationId: inv,
|
||||
question: input.question,
|
||||
options: input.options,
|
||||
recommendedId,
|
||||
expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(),
|
||||
timeoutAction: s.optionsTimeoutAction,
|
||||
};
|
||||
const dedupeKey = `${OPTIONS_KEY}:${run.id}:${inv}`;
|
||||
let d = await ctx.db
|
||||
.selectFrom("decision")
|
||||
@@ -622,6 +814,18 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
.executeTakeFirst();
|
||||
if (!d) {
|
||||
if (inv.startsWith("reconcile:")) throw new AwaitSuspendedError();
|
||||
const { views, bindings } = await resolveOptionActions(ctx.db, input.options);
|
||||
const evidence: OptionsEvidence = {
|
||||
source: "agent.propose_options",
|
||||
runId: run.id,
|
||||
invocationId: inv,
|
||||
question: input.question,
|
||||
options: views,
|
||||
recommendedId,
|
||||
expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(),
|
||||
timeoutAction: s.optionsTimeoutAction,
|
||||
bindings,
|
||||
};
|
||||
const stepId = await currentStepId(ctx, live, run.id);
|
||||
d = await insertDecision(ctx, {
|
||||
kind: "other",
|
||||
@@ -637,7 +841,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
invocationId: inv,
|
||||
decisionId: d.id,
|
||||
question: safe(input.question, 500),
|
||||
options: input.options.map((o) => ({
|
||||
options: views.map((o) => ({
|
||||
...o,
|
||||
title: safe(o.title, 200),
|
||||
summary: safe(o.summary, 1000),
|
||||
|
||||
@@ -220,15 +220,25 @@ export function registerAgentRoutes(
|
||||
runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)),
|
||||
);
|
||||
|
||||
route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => {
|
||||
route(app, Api.agentRuns.choose, async ({ params, body, actorId, c }) => {
|
||||
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!run) throw new ApiException("not_found", "智能体运行不存在");
|
||||
if (body.optionId === null && !body.reason?.trim())
|
||||
throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案");
|
||||
const r = await answerOptions(ctx, run.id, body, actorId);
|
||||
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
||||
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
||||
if (r === "closed") throw new ApiException("conflict", "这项方案选择已处理");
|
||||
// 选定即批准只认控制台管理员会话(与 PATCH /approvals 同一口径);API Key 作答只选方案
|
||||
const r = await answerOptions(ctx, run.id, body, actorId, {
|
||||
approver: c.get("principal")?.kind === "admin_session",
|
||||
});
|
||||
if (r.status === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
||||
if (r.status === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
||||
if (r.status === "closed") throw new ApiException("conflict", "这项方案选择已处理");
|
||||
if (r.status === "ok" && r.approvalId)
|
||||
await audit(ctx, actorId, "approval.approved", r.approvalId, {
|
||||
via: "option",
|
||||
runId: run.id,
|
||||
decisionId: body.decisionId,
|
||||
optionId: body.optionId,
|
||||
});
|
||||
const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
|
||||
return runToApi(fresh);
|
||||
});
|
||||
|
||||
@@ -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