Files
AgentLab/backend/tests/test_reject_exit_wording.py

198 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""驳回出口之后,待办、通知、定时任务的 Webhook 照实说「被驳回,转到 X」(docs/design/orchestration-round/00-总方案.md §3)。
实例页已经这么说(test_reject_exit.test_the_instance_says_rejected_and_where_it_went)。这里对的是另外几处:
- 待办:中断内容带着驳回之后去哪(reject_route):接了驳回出口的写转到哪几步,没接的写运行就此结束,审批模式没接、没设
驳回即终止的写这一路到此为止。待办卡的提示、驳回后的提示、处理过的结果照它说,不再一律写「转入驳回分支」「本次运行终止」;
- 子工作流里的关卡走了驳回出口:父运行的终态事件照实带上(「子工作流 › 把关」被驳回,转到「子工作流 › 退回」),实例页、
通知说这一句,不写内部 id、不写成「已完成」;
- 定时任务的 Webhook:途经驳回、照驳回那条路成功结束的,标题和状态写途经驳回,另起一行写谁被驳回、转到哪一步。
图和数据都是虚构的。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Task
from app.engine.runner import run_manager
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
START = node("start", "input", fields=[{"name": "q"}])
DONE = node("done", "output", "办结", fields=[{"name": "结果", "value": "通过了"}])
BACK = node("back", "output", "退回申请人", fields=[{"name": "结果", "value": "退回:{{ nodes.gate.note }}"}])
def gated(gate: dict, *, exit_to: dict | None = BACK) -> dict:
normal = "approved" if gate["data"]["config"].get("mode") == "approve" else "out"
nodes = [START, gate, DONE] + ([exit_to] if exit_to else [])
edges = [{"source": "start", "target": "gate"}, {"source": "gate", "target": "done", "sourceHandle": normal}]
if exit_to:
edges.append({"source": "gate", "target": exit_to["id"], "sourceHandle": "rejected"})
return {"nodes": nodes, "edges": edges}
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row is not None and row.status in statuses and not run_manager.is_active(run_id):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def _parked(graph: dict) -> tuple[Run, dict]:
"""发起、停在关卡上,交回运行和那条待办的中断内容。"""
run = await run_manager.start(graph=graph, input_payload={"q": "x"})
await _wait(run.id, ("interrupted",))
await run_manager.wait_idle(run.id)
async with SessionLocal() as session:
[task] = list((await session.execute(select(Task).where(Task.run_id == run.id))).scalars())
from app.db.models import Approval
approval = await session.get(Approval, task.approval_id)
return run, dict(approval.payload or {})
APPROVE = node("gate", "human", "把关", mode="approve", title="可以吗")
INPUT = node("gate", "human", "补充", mode="input", title="补一下")
TOOL = node("gate", "tool", "算一下", tool="calculator", args={"expression": "1+1"}, approval="always")
@pytest.mark.parametrize("gate", [APPROVE, INPUT, TOOL], ids=["approve", "input", "tool"])
async def test_the_task_says_where_a_rejection_goes(gate):
_, payload = await _parked(gated(gate))
assert payload["reject_route"] == {"to": [{"node_id": "back", "label": "退回申请人"}]}
@pytest.mark.parametrize("gate", [INPUT, TOOL], ids=["input", "tool"])
async def test_without_the_exit_the_task_says_the_run_ends(gate):
_, payload = await _parked(gated(gate, exit_to=None))
assert payload["reject_route"] == {"ends": "run"}
async def test_an_approval_without_the_exit_says_this_path_stops_here():
_, payload = await _parked(gated(APPROVE, exit_to=None))
assert payload["reject_route"] == {"ends": "path"}
stop = node("gate", "human", "把关", mode="approve", title="可以吗", stop_on_reject=True)
_, payload = await _parked(gated(stop))
assert payload["reject_route"] == {"ends": "run"}, "驳回即终止优先:驳回出口那条线走不到"
async def test_an_agent_tool_approval_has_no_route():
"""agent 自己调工具的审批不是关卡:驳回了 agent 照常往下想,中断内容不带 reject_route。"""
from app.engine.replay import reject_route
from app.engine.schema import GraphSpec
spec = GraphSpec.model_validate(gated(node("gate", "agent", "助手", prompt="x")))
class Ctx:
class run: # noqa: N801 - 只要 spec 和 global_id
pass
ctx = Ctx()
ctx.node = spec.node_map()["gate"]
ctx.run.spec = spec
assert reject_route(ctx) is None
# --------------------------------------------------------------------------
# 子工作流里的关卡走了驳回出口
# --------------------------------------------------------------------------
async def _sub_rejected() -> Run:
inner = {"nodes": [node("s", "input"), node("gate", "human", "把关", mode="approve", title="可以吗"),
node("ok", "output", "通过", fields=[{"name": "r", "value": "ok"}]),
node("back", "output", "退回", fields=[{"name": "r", "value": "退回:{{ nodes.gate.note }}"}])],
"edges": [{"source": "s", "target": "gate"},
{"source": "gate", "target": "ok", "sourceHandle": "approved"},
{"source": "gate", "target": "back", "sourceHandle": "rejected"}]}
sub_id = await S.publish("复核子流程", graph=inner, level="draft")
graph = S.chain(node("start", "input"), node("sub", "subgraph", "复核子流程", workflow_id=sub_id, workflow_version=1),
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
await _wait(run.id, ("interrupted",))
await run_manager.wait_idle(run.id)
await run_manager.resume(run.id, {"approved": False, "note": "不对"}, actor="张工")
return await _wait(run.id, ("cancelled", "failed", "succeeded"))
async def test_a_reject_exit_inside_a_subworkflow_is_named_in_the_parent():
from app.apps.resolve import _trails, instance_state
final = await _sub_rejected()
assert final.status == "succeeded", final.error
assert final.output == {"r": "退回:不对"}
async with SessionLocal() as session:
[ended] = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == final.id, RunEvent.type == "run.finished"))).scalars())
run = await session.get(Run, final.id)
state = instance_state(run, (await _trails(session, [run]))[run.id])
[via] = ended.data["rejected_via"]
assert (via["node_id"], via["label"]) == ("sub/gate", "复核子流程 › 把关")
assert via["to"] == [{"node_id": "sub/back", "label": "复核子流程 › 退回"}]
# 实例页、通知(notify_run 取的就是这一句):照实说被驳回、转到哪,不写「sub/gate」,不画成已完成
assert state == {"code": "cancelled", "text": "「复核子流程 › 把关」被驳回,转到「复核子流程 › 退回」"}
async def test_the_inner_task_names_the_route_with_the_subworkflow():
inner = {"nodes": [node("s", "input"), node("gate", "human", "补充", mode="input", title="补一下"),
node("back", "output", "退回", fields=[{"name": "r", "value": "x"}]),
node("ok", "output", "通过", fields=[{"name": "r", "value": "y"}])],
"edges": [{"source": "s", "target": "gate"}, {"source": "gate", "target": "ok"},
{"source": "gate", "target": "back", "sourceHandle": "rejected"}]}
sub_id = await S.publish("补充子流程", graph=inner, level="draft")
graph = S.chain(node("start", "input"), node("sub", "subgraph", "补充子流程", workflow_id=sub_id, workflow_version=1),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}]))
_, payload = await _parked(graph)
assert payload["reject_route"] == {"to": [{"node_id": "sub/back", "label": "补充子流程 › 退回"}]}
# --------------------------------------------------------------------------
# 定时任务的 Webhook
# --------------------------------------------------------------------------
def test_the_webhook_says_a_run_went_through_a_rejection():
from app.scheduler.webhook import Notice, message
via = [{"node_id": "gate", "label": "把关", "kind": "approve", "decision": "reject", "actor": "张工", "note": "不对",
"to": [{"node_id": "back", "label": "退回申请人"}]}]
title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC",
status="succeeded", rejected_via=via))
assert "途经驳回" in title
said = dict(lines)
assert said["状态"] == "已完成(途经驳回)"
assert said["驳回"] == "「把关」被驳回,转到「退回申请人」"
# 没途经驳回的照旧
title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC",
status="succeeded"))
assert title == "定时任务「周报」运行完成" and dict(lines)["状态"] == "已完成" and "驳回" not in dict(lines)
async def test_the_webhook_notice_reads_the_route_from_the_end_event():
from app.scheduler.webhook import _rejected_via_of
final = await _sub_rejected()
async with SessionLocal() as session:
run = await session.get(Run, final.id)
via = await _rejected_via_of(session, run)
assert [v["label"] for v in via] == ["复核子流程 › 把关"]