mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:11:58 +00:00
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 更新。
210 lines
9.6 KiB
Python
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']}")
|