Files
AgentLab/backend/tests/test_health_record.py
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

210 lines
9.6 KiB
Python

"""最近一次测连接的结果记在对象上,换台浏览器也看得到。
以前这份结果只存在当前浏览器的 localStorage 里:换台电脑、清个缓存,所有数据源、
模型接入、MCP 服务都回到「未测试」,而它们上周是不是连得通,谁也说不清。
记在对象上还有两条规矩:
- 在表单里测过、紧接着保存的,保存下来的对象直接带上那次结果——后端自己核对
过测的就是这份配置,不用前端转述。
- 连接配置改过了,旧结果就不再代表它,清掉;只改说明之类的不清。
"""
from __future__ import annotations
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Provider
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
def orders_db(tmp_path):
path = tmp_path / "orders.db"
db = sqlite3.connect(path)
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL)")
db.commit()
db.close()
return str(path)
def _health(item: dict) -> tuple:
return (item["last_check_ok"], item["last_error"],
item["last_checked_at"] is not None, item["last_latency_ms"] is not None)
async def _source(client, name: str) -> dict:
return next(s for s in (await client.get("/api/datasources")).json() if s["name"] == name)
# --------------------------------------------------------------------------
# 数据源
# --------------------------------------------------------------------------
async def test_testing_a_saved_source_is_remembered_on_it(client, orders_db):
r = await client.post("/api/datasources", json={"name": "health_src", "kind": "sqlite",
"database": orders_db})
src = r.json()
try:
assert _health(src) == (None, None, False, False), "没测过就是没测过"
body = (await client.post(f"/api/datasources/{src['id']}/test")).json()
assert body["ok"] is True
assert _health(await _source(client, "health_src")) == (True, None, True, True)
# 连接配置变了:旧结果不再代表它
r = await client.patch(f"/api/datasources/{src['id']}", json={"database": orders_db + ".gone"})
assert _health(r.json()) == (None, None, False, False)
body = (await client.post(f"/api/datasources/{src['id']}/test")).json()
assert body["ok"] is False
item = await _source(client, "health_src")
assert item["last_check_ok"] is False and item["last_error"] == body["error"]
assert item["last_checked_at"]
# 只改说明:连接没变,结果留着
r = await client.patch(f"/api/datasources/{src['id']}", json={"description": "订单库"})
assert r.json()["last_check_ok"] is False
finally:
await client.delete(f"/api/datasources/{src['id']}")
async def test_a_draft_tested_then_saved_keeps_its_result(client, orders_db):
body = (await client.post("/api/datasources/test", json={"kind": "sqlite",
"database": orders_db})).json()
assert body["ok"] is True
r = await client.post("/api/datasources", json={"name": "health_draft", "kind": "sqlite",
"database": orders_db})
src = r.json()
try:
assert _health(src) == (True, None, True, True), "刚在表单里测通的,保存之后不该变回未测试"
assert src["last_latency_ms"] == body["elapsed_ms"]
finally:
await client.delete(f"/api/datasources/{src['id']}")
async def test_a_draft_result_does_not_stick_to_a_different_config(client, orders_db, tmp_path):
await client.post("/api/datasources/test", json={"kind": "sqlite", "database": orders_db})
other = tmp_path / "other.db"
sqlite3.connect(other).close()
r = await client.post("/api/datasources", json={"name": "health_other", "kind": "sqlite",
"database": str(other)})
src = r.json()
try:
assert _health(src) == (None, None, False, False)
finally:
await client.delete(f"/api/datasources/{src['id']}")
async def test_a_draft_test_of_the_unchanged_saved_config_updates_it(client, orders_db):
r = await client.post("/api/datasources", json={"name": "health_edit", "kind": "sqlite",
"database": orders_db})
src = r.json()
try:
await client.post("/api/datasources/test", json={"id": src["id"], "kind": "sqlite",
"database": orders_db})
assert (await _source(client, "health_edit"))["last_check_ok"] is True
# 编辑框里改了路径再测:测的是另一份配置,不能记到已保存的那个头上
await client.post("/api/datasources/test", json={"id": src["id"], "kind": "sqlite",
"database": orders_db + ".typo"})
assert (await _source(client, "health_edit"))["last_check_ok"] is True
finally:
await client.delete(f"/api/datasources/{src['id']}")
# --------------------------------------------------------------------------
# 模型接入
# --------------------------------------------------------------------------
async def _provider(client, name: str) -> dict:
return next(p for p in (await client.get("/api/providers")).json() if p["name"] == name)
async def test_testing_a_saved_provider_is_remembered_on_it(client):
r = await client.post("/api/providers", json={"name": "health-mock", "kind": "mock",
"default_model": "mock-fast"})
prov = r.json()
try:
assert _health(prov) == (None, None, False, False)
assert (await client.post(f"/api/providers/{prov['id']}/test", json={})).json()["ok"] is True
item = await _provider(client, "health-mock")
assert _health(item) == (True, None, True, True)
assert "last_check" not in item["extra"], "结果是后端记的,不该混进用户填的 extra 里回显"
# 前端保存时会把 extra 原样带回来:不能因此把结果抹掉
r = await client.patch(f"/api/providers/{prov['id']}", json={"extra": item["extra"],
"enabled": True})
assert r.json()["last_check_ok"] is True
# 换了默认模型:测的那个不是它了
r = await client.patch(f"/api/providers/{prov['id']}", json={"default_model": "mock-slow"})
assert _health(r.json()) == (None, None, False, False)
finally:
await client.delete(f"/api/providers/{prov['id']}")
async def test_a_provider_draft_tested_then_saved_keeps_its_result(client):
body = (await client.post("/api/providers/test", json={"kind": "mock",
"default_model": "mock-fast"})).json()
assert body["ok"] is True
r = await client.post("/api/providers", json={"name": "health-draft", "kind": "mock",
"default_model": "mock-fast"})
prov = r.json()
try:
assert _health(prov) == (True, None, True, True)
finally:
await client.delete(f"/api/providers/{prov['id']}")
async def test_saving_a_provider_does_not_replace_its_headers_with_the_mask(client):
"""列表里请求头的值回显成 ***;设置页原样带回来保存,真值就被 *** 盖掉了。"""
r = await client.post("/api/providers", json={
"name": "health-headers", "kind": "openai_compatible", "base_url": "http://127.0.0.1:9/v1",
"api_key": "k", "default_model": "m", "extra": {"headers": {"X-Token": "real-value"}},
})
prov = r.json()
try:
shown = (await _provider(client, "health-headers"))["extra"]
assert shown["headers"] == {"X-Token": "***"}
await client.patch(f"/api/providers/{prov['id']}", json={
"extra": {**shown, "headers": {**shown["headers"], "X-Other": "new"}}})
async with SessionLocal() as session:
row = await session.get(Provider, prov["id"])
assert row.extra["headers"] == {"X-Token": "real-value", "X-Other": "new"}
finally:
await client.delete(f"/api/providers/{prov['id']}")
# --------------------------------------------------------------------------
# MCP
# --------------------------------------------------------------------------
async def test_probing_an_mcp_server_is_remembered_with_when_and_how_long(client):
r = await client.post("/api/mcp/servers", json={
"name": "health-mcp", "transport": "stdio", "command": "agentlab-no-such-command-xyz"})
server = r.json()
try:
assert _health(server) == (None, None, False, False)
body = (await client.post(f"/api/mcp/servers/{server['id']}/probe")).json()
assert body["ok"] is False
item = next(s for s in (await client.get("/api/mcp/servers")).json() if s["id"] == server["id"])
assert item["last_check_ok"] is False and item["last_error"] == body["error"]
assert item["last_checked_at"] and item["last_latency_ms"] is not None
# 改了启动命令:旧结论不再算数
r = await client.patch(f"/api/mcp/servers/{server['id']}", json={
"name": "health-mcp", "transport": "stdio", "command": "another-command"})
assert _health(r.json()) == (None, None, False, False)
assert r.json()["status"] == "unknown"
finally:
await client.delete(f"/api/mcp/servers/{server['id']}")