When children are still running, agent.await records the wait, closes the session (the tool call stays pending in the JSONL) and drive returns suspended. runAgent waits with waitForFact for the children to settle, the deadline, or cancellation, then reopens the session; agent.await is replay-safe, so resume replays it and the model gets the real result. Co-Authored-By: Claude Opus 5.5 <[email protected]>
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { type FakeScript, type FakeToolResult, isCapabilityError } from "@umt/agent";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./harness.ts";
|
|
|
|
// E11 Pi 子代理(agent.spawn / agent.await):父会话"检查两部作品缺什么版本"派生两个只读子会话 → 等待 → 汇总。
|
|
// - maxConcurrent=1:两个子会话串行(运行区间不重叠);
|
|
// - 子会话超预算(划拨 1 次工具调用却调了 2 次)→ budget_exceeded,不影响父会话;
|
|
// - 父会话合计用量 = 自身 + 子会话;子会话事件按父 seq 镜像进父事件流,SSE 可见;
|
|
// - 白名单超父 / 只给方案派生执行 / 深度上限 / 开关关闭 → 拒绝;
|
|
// - 父会话中止 → 子会话 cancelled。
|
|
|
|
const hasPg = await pgAvailable();
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
|
type Any = any;
|
|
|
|
const PARENT_GOAL = "检查两部作品缺什么版本";
|
|
const ABORT_GOAL = "中止测试:派生后等待";
|
|
const DUNE = "子目标:检查《沙丘2》缺什么版本";
|
|
const OPPIE = "子目标:检查《奥本海默》缺什么版本";
|
|
const READONLY = ["inventory.query", "catalog.lookup"];
|
|
|
|
const lastOk = (results: FakeToolResult[], cap: string): Any =>
|
|
[...results].reverse().find((r) => r.capabilityId === cap && !r.isError)?.json;
|
|
|
|
const parentScript: FakeScript = {
|
|
name: "e11-parent",
|
|
match: PARENT_GOAL,
|
|
steps: [
|
|
{
|
|
text: "两部作品互不相关,各派一个只读子会话。",
|
|
toolCalls: [
|
|
{
|
|
capability: "agent.spawn",
|
|
input: { goal: DUNE, capabilityAllowlist: READONLY, mode: "plan_only", reason: "逐部检查" },
|
|
},
|
|
{
|
|
capability: "agent.spawn",
|
|
input: {
|
|
goal: OPPIE,
|
|
capabilityAllowlist: READONLY,
|
|
mode: "plan_only",
|
|
reason: "逐部检查(小预算)",
|
|
budget: { maxToolCalls: 1 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{ toolCalls: [{ capability: "agent.await", input: { timeoutSec: 120 } }] },
|
|
],
|
|
finalText: (results) => {
|
|
const r = lastOk(results, "agent.await");
|
|
const lines = (r?.results ?? []).map((x: Any) => `${x.goal} → ${x.status}`);
|
|
return `汇总:${lines.join(";")}`;
|
|
},
|
|
};
|
|
|
|
const duneScript: FakeScript = {
|
|
name: "e11-dune",
|
|
match: DUNE,
|
|
steps: [{ toolCalls: [{ capability: "inventory.query", input: { query: "沙丘2", limit: 5 } }] }],
|
|
finalText: "《沙丘2》:库里没有 1080p 版本",
|
|
};
|
|
|
|
// 只划拨了 1 次工具调用:第二次调用超预算
|
|
const oppieScript: FakeScript = {
|
|
name: "e11-oppie",
|
|
match: OPPIE,
|
|
steps: [
|
|
{ toolCalls: [{ capability: "inventory.query", input: { query: "奥本海默", limit: 5 } }] },
|
|
{ toolCalls: [{ capability: "catalog.lookup", input: { query: "奥本海默" } }] },
|
|
],
|
|
finalText: "不应走到这里",
|
|
};
|
|
|
|
const abortParentScript: FakeScript = {
|
|
name: "e11-abort",
|
|
match: ABORT_GOAL,
|
|
steps: [
|
|
{
|
|
toolCalls: [
|
|
{ capability: "agent.spawn", input: { goal: "子目标:慢任务甲", capabilityAllowlist: READONLY } },
|
|
{ capability: "agent.spawn", input: { goal: "子目标:慢任务乙", capabilityAllowlist: READONLY } },
|
|
],
|
|
},
|
|
{ toolCalls: [{ capability: "agent.await", input: { timeoutSec: 600 } }] },
|
|
],
|
|
finalText: "不应走到这里",
|
|
};
|
|
|
|
const slowChild: FakeScript = {
|
|
name: "e11-slow",
|
|
match: /^子目标:慢任务/,
|
|
steps: [{ toolCalls: [{ capability: "inventory.query", input: { limit: 1 } }] }],
|
|
finalText: "done",
|
|
};
|
|
|
|
describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
|
let e: E2E;
|
|
|
|
beforeAll(async () => {
|
|
e = await createE2E("e11", { fakeScripts: [parentScript, duneScript, oppieScript, abortParentScript, slowChild] });
|
|
await e.settings({ pi: { subagents: { enabled: true, maxConcurrent: 1, maxDepth: 1 } } });
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await e?.close();
|
|
}, 60_000);
|
|
|
|
const settle = (runId: string, statuses: string[], ms = 90_000) =>
|
|
waitFor(
|
|
async () => {
|
|
const r = await runRow(e, runId);
|
|
return statuses.includes(r.status) ? r : null;
|
|
},
|
|
ms,
|
|
`run ${runId} → ${statuses.join("/")}`,
|
|
);
|
|
|
|
it("派生两个只读子会话并等待 → 汇总;串行、子超预算不影响父、预算合计、子事件进父事件流", async () => {
|
|
const seen: Any[] = [];
|
|
const off = e.bus.subscribe((ev) => {
|
|
if (ev.type === "agent.tool_call") seen.push(ev);
|
|
});
|
|
const r = await e.api("POST", "/agent/runs", {
|
|
goal: PARENT_GOAL,
|
|
mode: "plan_only",
|
|
capabilityAllowlist: [...READONLY, "agent.spawn", "agent.await"],
|
|
});
|
|
expect(r.status).toBe(201);
|
|
const parentId = r.body.data.id as string;
|
|
const parent = await settle(parentId, ["succeeded", "failed", "cancelled", "budget_exceeded"]);
|
|
off();
|
|
expect(parent.status).toBe("succeeded");
|
|
|
|
const kids = await e.db
|
|
.selectFrom("agent_run")
|
|
.selectAll()
|
|
.where("parent_run_id", "=", parentId)
|
|
.orderBy("created_at")
|
|
.execute();
|
|
expect(kids.map((k) => k.goal)).toEqual([DUNE, OPPIE]);
|
|
for (const k of kids) {
|
|
expect(k).toMatchObject({ trigger: "subagent", depth: 1, mode: "plan_only", created_by: parent.created_by });
|
|
expect((k.scope as Any).capabilities).toEqual(READONLY);
|
|
}
|
|
const [dune, oppie] = kids as [(typeof kids)[0], (typeof kids)[0]];
|
|
expect(dune.status).toBe("succeeded");
|
|
expect(dune.spawn_reason).toBe("逐部检查");
|
|
// 子超预算:budget_exceeded,不影响父会话
|
|
expect(oppie.status).toBe("budget_exceeded");
|
|
expect((oppie.budget as Any).maxToolCalls).toBe(1);
|
|
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 40,派生时父已用 1 次)
|
|
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(20);
|
|
|
|
// maxConcurrent=1:串行(谁先占到名额不定,但两个运行区间不重叠)
|
|
const [first, second] = [dune, oppie].sort((a, b) => a.started_at!.getTime() - b.started_at!.getTime()) as [
|
|
typeof dune,
|
|
typeof dune,
|
|
];
|
|
expect(first.finished_at).toBeTruthy();
|
|
expect(second.started_at!.getTime()).toBeGreaterThanOrEqual(first.finished_at!.getTime());
|
|
|
|
// 父会话汇总来自 agent.await 的结果
|
|
const events = await runEvents(e, parentId);
|
|
const done = events.find((x) => x.type === "run_completed");
|
|
expect((done!.payload as Any).finalText).toContain(`${DUNE} → succeeded`);
|
|
expect((done!.payload as Any).finalText).toContain(`${OPPIE} → budget_exceeded`);
|
|
|
|
// 子会话事件进父事件流(父 seq 单调、不冲突)
|
|
const types = events.map((x) => x.type);
|
|
expect(types.filter((t) => t === "subagent_spawned")).toHaveLength(2);
|
|
expect(types.filter((t) => t === "subagent_completed")).toHaveLength(2);
|
|
const mirrored = events.filter((x) => x.type === "subagent_event");
|
|
expect(
|
|
mirrored.some(
|
|
(x) => (x.payload as Any).childRunId === dune.id && (x.payload as Any).capabilityId === "inventory.query",
|
|
),
|
|
).toBe(true);
|
|
expect(events.map((x) => Number(x.seq))).toEqual(events.map((_, i) => i));
|
|
// 子会话结束记录先于 agent.await 的工具结果
|
|
const awaitEnd = events.find(
|
|
(x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "agent.await",
|
|
)!;
|
|
const lastCompleted = events.filter((x) => x.type === "subagent_completed").at(-1)!;
|
|
expect(Number(lastCompleted.seq)).toBeLessThan(Number(awaitEnd.seq));
|
|
// agent.await 挂起(不占 worker):会话关闭 → waitForFact 等子会话 → 重开会话重放 agent.await
|
|
expect((awaitEnd.payload as Any).recovery).toBe(true);
|
|
expect(events.filter((x) => x.type === "run_started").map((x) => (x.payload as Any).resumed)).toEqual([
|
|
false,
|
|
true,
|
|
]);
|
|
// SSE:父会话 runId 上能看到子会话提示
|
|
expect(seen.some((x) => x.payload.runId === parentId && x.payload.capabilityId === "agent.spawn")).toBe(true);
|
|
|
|
// 预算合计:父会话合计 = 自身 + 子会话
|
|
const detail = await e.api("GET", `/agent/runs/${parentId}?afterSeq=-1&limit=500`);
|
|
const d = detail.body.data;
|
|
const ownTokens = (parent.usage as Any).totalTokens as number;
|
|
const kidTokens = kids.reduce((a, k) => a + ((k.usage as Any).totalTokens ?? 0), 0);
|
|
expect(kidTokens).toBeGreaterThan(0);
|
|
expect(d.familyUsage).toMatchObject({
|
|
runs: 3,
|
|
toolCalls: parent.tool_call_count + dune.tool_call_count + oppie.tool_call_count,
|
|
totalTokens: ownTokens + kidTokens,
|
|
});
|
|
expect(parent.tool_call_count).toBe(3);
|
|
expect(d.children.map((c: Any) => c.id)).toEqual([dune.id, oppie.id]);
|
|
expect(parent.summary).toContain("子会话 2 个");
|
|
// 子会话详情:父会话链接与继承信息
|
|
const child = await e.api("GET", `/agent/runs/${dune.id}`);
|
|
expect(child.body.data.parent).toMatchObject({ id: parentId, goal: PARENT_GOAL });
|
|
expect(child.body.data.run).toMatchObject({ parentRunId: parentId, depth: 1, spawnReason: "逐部检查" });
|
|
// 列表可按父会话过滤
|
|
const list = await e.api("GET", `/agent/runs?parentRunId=${parentId}`);
|
|
expect(list.body.data.items).toHaveLength(2);
|
|
}, 180_000);
|
|
|
|
it("拒绝:白名单超父、只给方案派生执行、深度上限、开关关闭", async () => {
|
|
const parent = await e.db
|
|
.insertInto("agent_run")
|
|
.values({
|
|
trigger: "user",
|
|
goal: "fixture parent",
|
|
status: "running",
|
|
mode: "plan_only",
|
|
scope: JSON.stringify({
|
|
capabilities: [...READONLY, "agent.spawn", "agent.await"],
|
|
allowedKinds: ["read", "preview", "propose"],
|
|
}),
|
|
budget: JSON.stringify({}),
|
|
created_by: "user:admin",
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
const child = await e.db
|
|
.insertInto("agent_run")
|
|
.values({
|
|
trigger: "subagent",
|
|
goal: "fixture child",
|
|
status: "running",
|
|
mode: "plan_only",
|
|
scope: JSON.stringify({
|
|
capabilities: [...READONLY, "agent.spawn"],
|
|
allowedKinds: ["read", "preview", "propose"],
|
|
}),
|
|
budget: JSON.stringify({}),
|
|
created_by: "user:admin",
|
|
parent_run_id: parent.id,
|
|
depth: 1,
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
const spawn = async (runId: string, input: Record<string, unknown>) => {
|
|
try {
|
|
await e.booted.ctx.capabilities.invoke("agent.spawn", input, {
|
|
actorId: "user:admin",
|
|
taskId: runId,
|
|
scopeIds: [],
|
|
signal: new AbortController().signal,
|
|
source: "agent",
|
|
agentRunId: runId,
|
|
});
|
|
return null;
|
|
} catch (err) {
|
|
if (!isCapabilityError(err)) throw err;
|
|
return { code: err.code, message: err.message };
|
|
}
|
|
};
|
|
expect(await spawn(parent.id, { goal: "越权", capabilityAllowlist: ["release.search"] })).toMatchObject({
|
|
code: "unauthorized",
|
|
message: expect.stringContaining("超出本会话白名单"),
|
|
});
|
|
expect(await spawn(parent.id, { goal: "提权", mode: "execute" })).toMatchObject({
|
|
code: "unauthorized",
|
|
message: expect.stringContaining("只给方案"),
|
|
});
|
|
expect(await spawn(child.id, { goal: "再派生" })).toMatchObject({
|
|
code: "precondition",
|
|
message: expect.stringContaining("派生层数已达上限"),
|
|
});
|
|
await e.settings({ pi: { subagents: { enabled: false } } });
|
|
expect(await spawn(parent.id, { goal: "关闭后" })).toMatchObject({
|
|
code: "precondition",
|
|
message: expect.stringContaining("子会话未开启"),
|
|
});
|
|
await e.settings({ pi: { subagents: { enabled: true } } });
|
|
// 以上都没有建出子会话
|
|
const n = await e.db.selectFrom("agent_run").select("id").where("parent_run_id", "=", parent.id).execute();
|
|
expect(n.map((x) => x.id)).toEqual([child.id]);
|
|
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "in", [parent.id, child.id]).execute();
|
|
}, 60_000);
|
|
|
|
it("父会话中止 → 全部子会话 cancelled(排队等名额的也一样)", async () => {
|
|
// 先停 worker 建父会话,再塞一个占着唯一名额的子会话,保证真实子会话排队等名额
|
|
await e.worker?.stop();
|
|
const r = await e.api("POST", "/agent/runs", {
|
|
goal: ABORT_GOAL,
|
|
mode: "plan_only",
|
|
capabilityAllowlist: [...READONLY, "agent.spawn", "agent.await"],
|
|
});
|
|
const parentId = r.body.data.id as string;
|
|
const blocker = await e.db
|
|
.insertInto("agent_run")
|
|
.values({
|
|
trigger: "subagent",
|
|
goal: "占名额",
|
|
status: "running",
|
|
mode: "plan_only",
|
|
scope: JSON.stringify({ capabilities: READONLY }),
|
|
budget: JSON.stringify({}),
|
|
parent_run_id: parentId,
|
|
depth: 1,
|
|
started_at: new Date(),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await e.startWorker();
|
|
|
|
const kids = await waitFor(
|
|
async () => {
|
|
const rows = await e.db
|
|
.selectFrom("agent_run")
|
|
.selectAll()
|
|
.where("parent_run_id", "=", parentId)
|
|
.where("id", "<>", blocker.id)
|
|
.execute();
|
|
const p = await runEvents(e, parentId);
|
|
const awaiting = p.some(
|
|
(x) => x.type === "tool_call_start" && (x.payload as Any).capabilityId === "agent.await",
|
|
);
|
|
return rows.length === 2 && awaiting ? rows : null;
|
|
},
|
|
60_000,
|
|
"父会话派生两个子会话并开始等待",
|
|
);
|
|
expect(kids.every((k) => k.status === "queued")).toBe(true);
|
|
|
|
const ab = await e.api("POST", `/agent/runs/${parentId}/abort`, { reason: "e11 中止" });
|
|
expect(ab.status).toBe(200);
|
|
const parent = await settle(parentId, ["cancelled", "succeeded", "failed"]);
|
|
expect(parent.status).toBe("cancelled");
|
|
for (const k of kids) {
|
|
const c = await settle(k.id, ["cancelled", "succeeded", "failed"]);
|
|
expect(c.status).toBe("cancelled");
|
|
expect(c.cancel_requested_at).not.toBeNull();
|
|
}
|
|
// 占名额的行也收到取消标志
|
|
const b = await runRow(e, blocker.id);
|
|
expect(b.cancel_requested_at).not.toBeNull();
|
|
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", blocker.id).execute();
|
|
}, 120_000);
|
|
});
|