Merge branch 'ag4'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
root committed 2026-09-30 18:21:57 +08:00
commit 09bac0ea39
28 files changed
+1011 -59

No files matched your search

@@ -358,6 +358,88 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "in", [parent.id, child.id]).execute();
}, 60_000);
it("同目标去重(AG-4):子会话还在运行或 10 分钟内已完成时返回已有子会话,不新建", async () => {
const parent = await e.db
.insertInto("agent_run")
.values({
trigger: "user",
goal: "fixture dedupe 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: "子目标:查《沙丘》 缺什么版本",
status: "running",
mode: "plan_only",
scope: JSON.stringify({ capabilities: READONLY, allowedKinds: ["read", "preview", "propose"] }),
budget: JSON.stringify({ maxToolCalls: 5 }),
created_by: "user:admin",
parent_run_id: parent.id,
depth: 1,
started_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow();
const spawn = async (input: Record<string, unknown>, key: string) =>
(await e.booted.ctx.capabilities.invoke("agent.spawn", input, {
actorId: "user:admin",
taskId: parent.id,
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
agentRunId: parent.id,
invocationId: key,
})) as Any;
const children = async () =>
(await e.db.selectFrom("agent_run").select("id").where("parent_run_id", "=", parent.id).execute()).map(
(x) => x.id,
);
// 仍在运行:归一化后同目标(全半角标点、空白不同)→ 复用,结果摘要 pending
const a = await spawn({ goal: "子目标:查《沙丘》缺什么版本。", capabilityAllowlist: READONLY }, "dedupe-1");
expect(a).toMatchObject({ reused: true, runId: child.id, result: { pending: true, summary: null } });
expect(a.message).toContain("已有同目标子会话");
expect(Object.keys(a)[0]).toBe("reused");
// 5 分钟前已完成:复用并带回汇报摘要
await e.db
.updateTable("agent_run")
.set({ status: "succeeded", summary: "《沙丘》缺 4K 版本", finished_at: new Date(Date.now() - 5 * 60_000) })
.where("id", "=", child.id)
.execute();
const b = await spawn({ goal: "子目标:查《沙丘》缺什么版本" }, "dedupe-2");
expect(b).toMatchObject({
reused: true,
runId: child.id,
result: { status: "succeeded", pending: false, summary: expect.stringContaining("缺 4K") },
});
expect(b.message).toContain("10 分钟内已完成");
// 结果行的子会话计数(会话树)不变
expect(await children()).toEqual([child.id]);
// 超过 10 分钟 / 失败的不复用;不同目标不复用
const { findSameGoalChild, normalizeGoal } = await import("../../src/modules/agent/subagents.ts");
expect(normalizeGoal("子目标:查《沙丘》 缺什么版本。")).toBe(normalizeGoal("子目标:查《沙丘》缺什么版本"));
const later = Date.now() + 6 * 60_000;
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本", later)).toBeNull();
expect((await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本"))?.id).toBe(child.id);
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《奥本海默》缺什么版本")).toBeNull();
await e.db.updateTable("agent_run").set({ status: "failed" }).where("id", "=", child.id).execute();
expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本")).toBeNull();
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", parent.id).execute();
}, 60_000);
it("父会话中止 → 全部子会话 cancelled(排队等名额的也一样)", async () => {
// 先停 worker 建父会话,再塞一个占着唯一名额的子会话,保证真实子会话排队等名额
await e.worker?.stop();