Files
AgentLab/backend/tests/test_validation_text.py
T
ThearchyHelios 587bf7e27c 第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」,
校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮,
轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。

平台可信度(先写失败测试)
- Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则,
  工具集合变小时自查报警、改图回执列出变化
- 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修
- 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量
- 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因
- 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员
- 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行

后端
- 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、
  SQLite progress handler),可按数据源配置;超时报错与实际耗时一致
- 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存;
  自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存;
  检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors

前端
- 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时,
  一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子
- 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚;
  回放用量取那一刻的值
- 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口
- 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对
- 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条
- 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问

验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿;
check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
2026-09-27 10:16:32 +08:00

209 lines
9.0 KiB
Python
Raw 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.
"""校验和报错写给人看:用界面上的叫法,说清原因和下一步,不露 Python 内部名字。
- 分支 case 的 key 用了保留名 default:和兜底出口撞成同一个 id,跑完两条一起点亮,
看不出实际走了哪条。重复 key、空 key 更糟——那个 case 根本路由不到。
- 门禁文案写「受管模板不允许 supervisor 节点」「approval=never」「该是个 llm 节点」,
界面上它们叫「多 Agent 协作」「全部自动放行」「模型调用」,报错引用了用户从没见过的词。
- 运行失败的首行写着「工具 db_query__shop 执行失败:_make_query_tool.<locals>._run()
got an unexpected keyword argument 'query'」,恢复失败写「当前状态是 succeeded」。
"""
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
from app.engine import compiler
from app.engine.governance import lint_for_publish
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, validate_graph
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": label or nid, "config": config}}
def _branch(cases: list[dict]) -> GraphSpec:
return GraphSpec.model_validate({
"nodes": [
node("start", "input"),
node("br", "branch", label="走哪条", mode="expression", cases=cases),
node("a", "output"), node("b", "output"),
],
"edges": [
{"source": "start", "target": "br"},
{"source": "br", "target": "a", "sourceHandle": "fast"},
{"source": "br", "target": "b", "sourceHandle": "default"},
],
})
def _issues(spec: GraphSpec, level: str) -> list[str]:
return [i.message for i in validate_graph(spec).issues if i.level == level and i.node_id == "br"]
def test_case_key_default_is_flagged_as_the_reserved_fallback():
spec = _branch([{"key": "fast", "condition": "true"},
{"key": "default", "condition": "true", "label": "协作模式"}])
warnings = _issues(spec, "warning")
assert any("default" in m and "保留" in m for m in warnings), warnings
# 现存的 ⑥ 模板就是这么写的:条件为 true 的 default 等价于兜底,运行语义自洽。
# 升成 error 会让它和所有从它复制出来的图直接不能跑
assert validate_graph(spec).ok
def test_duplicate_case_keys_are_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": "fast", "condition": "vars.b"}])
errors = _issues(spec, "error")
assert any("fast" in m and "重复" in m for m in errors), errors
assert not validate_graph(spec).ok
def test_blank_case_key_is_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": " ", "condition": "vars.b"}])
assert any("标识" in m for m in _issues(spec, "error")), _issues(spec, "error")
def test_validation_uses_the_names_on_the_canvas():
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), node("sub", "subgraph"), node("t", "tool"),
node("v", "validate"), node("out", "output")],
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "t"},
{"source": "t", "target": "v"}, {"source": "v", "target": "out"}],
})
messages = " ".join(i.message for i in validate_graph(spec).issues)
assert "子工作流" in messages and "子图节点" not in messages
assert "「调用工具」" in messages
assert "结构校验" in messages
def _governed(*nodes) -> str:
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), *nodes],
"edges": [{"source": "start", "target": n["id"]} for n in nodes],
})
return " ".join(i.message for i in lint_for_publish(spec, level="governed").issues)
def test_governance_speaks_the_ui_vocabulary():
text = _governed(
node("team", "supervisor", label="协作团队"),
node("ag", "agent", label="取数员", tools=["web_search"], approval="never"),
node("bare", "agent", label="空手的"),
node("c", "code", label="算一下", network=True),
)
for internal in ("supervisor", "approval=never", "llm 节点", "contract"):
assert internal not in text, f"门禁文案里还有内部词 {internal!r}:{text}"
assert "「协作团队」" in text and "多 Agent 协作" in text
assert "审批策略" in text and "全部自动放行" in text
assert "模型调用" in text
assert "出具契约" in text
# --------------------------------------------------------------------------
# 运行期报错
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _finish(run_id: str) -> Run:
for _ in range(300):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
return row
await asyncio.sleep(0.03)
raise AssertionError("等超时了")
RAW = "_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'"
async def test_a_python_error_reads_like_a_sentence(engine_up, monkeypatch):
async def broken(state, ctx):
raise TypeError(RAW)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, broken)
graph = {
"nodes": [node("start", "input"), node("shape", "transform", label="整理数据",
mode="template", template="x")],
"edges": [{"source": "start", "target": "shape"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "failed"
for text in (row.error or "",):
assert "TypeError" not in text and "<locals>" not in text, text
assert "query" in text, "原因要说到点子上:哪个参数对不上"
assert row.error_node_id == "shape"
async with SessionLocal() as session:
events = list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)).scalars())
failed_node = next(e for e in events if e.type == "node.failed")
assert "TypeError" not in failed_node.data["error"]
assert "TypeError" in failed_node.data["detail"], "原始异常要留着,给排查的人看"
failed_run = next(e for e in events if e.type == "run.failed")
assert failed_run.data["node_id"] == "shape"
assert failed_run.data["label"] == "整理数据"
assert "TypeError" not in failed_run.data["error"]
async def test_resume_refusal_does_not_leak_the_status_enum(engine_up):
async with SessionLocal() as session:
run = Run(workflow_name="x", status="succeeded", graph={"nodes": [], "edges": []}, input={})
session.add(run)
await session.commit()
with pytest.raises(ValueError) as caught:
await run_manager.resume(run.id, {"approved": True})
assert "succeeded" not in str(caught.value)
assert "已完成" in str(caught.value)
def test_exception_wording_helper():
from app.core.errors import describe_exception
assert "query" in describe_exception(TypeError(RAW))
assert "TypeError" not in describe_exception(TypeError(RAW))
timeout = describe_exception(TimeoutError())
assert timeout and "Timeout" not in timeout
generic = describe_exception(RuntimeError("Integer exceeds 64-bit range"))
assert "RuntimeError" not in generic and "64-bit" in generic
nested = describe_exception(RuntimeError("连接失败:ConnectError: All connection attempts failed"))
assert "Error" not in nested and "All connection attempts failed" in nested
async def test_issuance_event_says_why_it_was_downgraded(engine_up):
"""只有 gaps 时判成降档,事件里却不带 gaps:时间线上只剩一个光秃秃的「降档出具」,
横幅还说「请对照下方声明」,下方什么都没有。"""
graph = {
"nodes": [
node("start", "input"),
node("out", "output", fields=[{"name": "结论", "value": "本周 12 单"}],
contract={"metrics_from": "没有这个口径卡", "narrative": "{{ output.结论 }}"}),
],
"edges": [{"source": "start", "target": "out"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
issuance = (await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance")
)).scalars().one()
assert issuance.data["tier"] == "degraded"
assert any("没有这个口径卡" in g for g in issuance.data["gaps"]), issuance.data
assert issuance.data["metrics_checked"] == 0