Files
AgentLab/backend/tests/test_api_messages.py
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

266 lines
10 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 异常类名和内部符号,并且说得出下一步。
以前界面首行红字是「_make_query_tool.<locals>._run() got an unexpected keyword
argument 'query'」,助手输入坞里是「RuntimeError: …」,校验失败是一大段 pydantic
英文。对问数据的业务用户这些都是天书,而且没有下一步。原始异常不丢:放进 detail,
排查的人照样拿得到。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
_INTERNAL = ("Error:", "Error)", "Exception", "Traceback", "<locals>", "Input should be",
"validation error", "errors.pydantic.dev")
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def _clean(text: str) -> None:
for token in _INTERNAL:
assert token not in text, f"面向用户的报错里露出了 {token!r}:{text}"
# --------------------------------------------------------------------------
# 翻译规则本身
# --------------------------------------------------------------------------
def test_common_failures_are_put_in_plain_words():
import httpx
from app.api.errors import explain, graph_error, raw
reason, hint = explain(httpx.ConnectError("[Errno 61] Connection refused"))
assert "连不上" in reason and hint
req = httpx.Request("GET", "https://x/v1/models")
denied = httpx.HTTPStatusError("401", request=req, response=httpx.Response(401, request=req))
assert "密钥" in explain(denied)[0]
reason, hint = explain(TypeError("_run() got an unexpected keyword argument 'query'"))
_clean(reason)
assert "内部" in reason and hint
assert raw(TypeError("x")) == "TypeError: x", "原始异常要原样留给 detail"
from app.engine.schema import GraphSpec
with pytest.raises(Exception) as e:
GraphSpec.model_validate({"nodes": [{"id": "a", "type": "nope"}]})
text = graph_error(e.value)
_clean(text)
assert "第 1 个节点" in text and "nope" in text
# --------------------------------------------------------------------------
# 各接口
# --------------------------------------------------------------------------
async def test_a_failing_provider_test_explains_itself(client, monkeypatch):
import httpx
import app.api.settings as settings_api
class Boom:
async def ainvoke(self, _prompt):
raise httpx.ConnectError("[Errno 61] Connection refused")
monkeypatch.setattr(settings_api, "build_chat_model", lambda *_a, **_k: Boom())
body = (await client.post("/api/providers/test", json={"kind": "mock", "default_model": "m"})).json()
assert body["ok"] is False
_clean(body["error"])
assert "连不上" in body["error"] and body["hint"]
assert "ConnectError" in body["detail"], "技术细节里要留着原始异常"
async def test_copilot_stream_errors_are_readable(client, monkeypatch):
import app.api.copilot as copilot
class Broken:
async def astream(self, _messages):
raise RuntimeError("upstream closed")
yield # pragma: no cover
async def _model(*_a, **_k):
return Broken(), "mock-fast"
monkeypatch.setattr(copilot, "get_chat_model", _model)
events = []
async with client.stream("POST", "/api/copilot/generate-stream", json={"instruction": "x"}) as r:
async for line in r.aiter_lines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
err = next(e for e in events if e["op"] == "error")
_clean(err["message"])
assert "upstream closed" in err["message"] and "RuntimeError" in err["detail"]
async def test_copilot_generate_errors_are_readable(client, monkeypatch):
import app.api.copilot as copilot
class Garbage:
def with_structured_output(self, _schema):
return self
async def ainvoke(self, _messages):
raise ValueError("not json")
async def _model(*_a, **_k):
return Garbage(), "mock-fast"
monkeypatch.setattr(copilot, "get_chat_model", _model)
r = await client.post("/api/copilot/generate", json={"instruction": "x"})
assert r.status_code == 502
_clean(r.json()["detail"])
async def test_graph_validation_points_at_the_node(client):
body = (await client.post("/api/workflows/validate",
json={"graph": {"nodes": [{"id": "a", "type": "nope"}]}})).json()
message = body["issues"][0]["message"]
_clean(message)
assert "第 1 个节点的类型「nope」不认识" in message
async def test_publish_level_uses_ui_words(client):
wf = (await client.post("/api/workflows", json={"name": "档位", "graph": {}})).json()
r = await client.post(f"/api/workflows/{wf['id']}/publish", json={"level": "gold"})
assert r.status_code == 400
assert "「已发布」" in r.json()["detail"] and "governed" not in r.json()["detail"]
await client.delete(f"/api/workflows/{wf['id']}")
async def test_a_failing_tool_run_explains_itself(client, monkeypatch):
import app.api.tools as tools_api
async def boom(*_a, **_k):
raise TypeError("_run() got an unexpected keyword argument 'query'")
monkeypatch.setattr(tools_api, "call_tool", boom)
body = (await client.post("/api/tools/file_list/run", json={"args": {}})).json()
assert body["ok"] is False
_clean(body["error"])
assert "TypeError" in body["detail"]
async def test_datasource_introspect_failure_is_readable(client):
r = await client.post("/api/datasources", json={"name": "ghostfile", "kind": "sqlite",
"database": "/no/such/dir/ghost.db"})
assert r.status_code == 201
sid = r.json()["id"]
try:
r = await client.post(f"/api/datasources/{sid}/introspect")
assert r.status_code == 400
_clean(r.json()["detail"])
body = (await client.post(f"/api/datasources/{sid}/test")).json()
assert body["ok"] is False
_clean(body["error"])
assert body["detail"], "原始报错要留在 detail"
finally:
await client.delete(f"/api/datasources/{sid}")
async def test_embedding_probe_failure_is_readable(client):
r = await client.get("/api/kb/embedding/probe", params={"base_url": "http://127.0.0.1:9/v1"})
assert r.status_code == 400
_clean(r.json()["detail"])
def test_a_broken_document_is_explained_without_class_names():
pytest.importorskip("pypdf")
from app.memory.parsing import UnsupportedDocument, extract
with pytest.raises(UnsupportedDocument) as e:
extract(b"%PDF-1.4 definitely not a pdf", "broken.pdf")
_clean(str(e.value))
assert "broken" not in str(e.value) or "PDF" in str(e.value)
async def test_background_processing_failure_is_readable(monkeypatch):
from app.api import knowledge as kb_api
from app.db.base import SessionLocal
from app.db.models import Document
from app.memory import kb
async with SessionLocal() as session:
doc = await kb.create_document(session, collection="errs", title="t", content="正文",
source="t.txt", mime="text/plain", status="processing")
doc_id = doc.id
async def boom(*_a, **_k):
raise AttributeError("'NoneType' object has no attribute 'encode'")
monkeypatch.setattr(kb, "process_document", boom)
await kb_api._process_in_background(doc_id)
async with SessionLocal() as session:
doc = await session.get(Document, doc_id)
assert doc.status == "failed"
_clean(doc.error)
async def test_governance_hints_do_not_quote_field_names(client):
wf = (await client.post("/api/workflows", json={"name": "治理", "graph": {}})).json()
try:
body = (await client.get("/api/governance/tool-usage", params={"workflow_id": wf["id"]})).json()
assert "unused" not in body["hint"]
finally:
await client.delete(f"/api/workflows/{wf['id']}")
body = (await client.get("/api/governance/exploratory-clusters")).json()
assert "promote_candidate" not in body.get("hint", "")
async def test_an_unconfigured_copilot_model_uses_settings_words(client, monkeypatch):
import app.api.copilot as copilot
from app.providers.factory import ProviderNotConfigured
async def _model(*_a, **_k):
raise ProviderNotConfigured("provider 'gw' 需要填 base_url")
monkeypatch.setattr(copilot, "get_chat_model", _model)
r = await client.post("/api/copilot/generate-stream", json={"instruction": "x"})
assert r.status_code == 400
detail = r.json()["detail"]
assert "模型接入「gw」" in detail and "Base URL" in detail and "provider" not in detail, detail
async def test_a_missing_item_says_it_may_have_been_deleted(client):
"""404 多半是别的标签页里删掉了,或者链接过期了。只说「不存在」,人会以为
是自己输错了什么;统一说一句「可能已经被删了」,下一步就清楚了。"""
gone = "no-such-id"
calls = [
("get", f"/api/workflows/{gone}", None),
("patch", f"/api/workflows/{gone}", {"name": "x"}),
("delete", f"/api/workflows/{gone}", None),
("post", f"/api/workflows/{gone}/duplicate", None),
("get", f"/api/workflows/{gone}/versions/1", None),
("post", f"/api/workflows/{gone}/versions/1/restore", None),
("post", f"/api/workflows/{gone}/publish", {}),
("get", "/api/governance/tool-usage", None),
("post", "/api/copilot/from-run", {"run_id": gone}),
("post", "/api/copilot/review", {"run_id": gone}),
("get", f"/api/conversations/{gone}", None),
("get", f"/api/kb/documents/{gone}", None),
("delete", f"/api/kb/documents/{gone}", None),
("delete", f"/api/skills/{gone}", None),
("get", f"/api/artifacts/{'0' * 64}", None),
]
for method, url, body in calls:
kwargs = {"json": body} if body is not None else {}
if url.endswith("tool-usage"):
kwargs = {"params": {"workflow_id": gone}}
r = await client.request(method.upper(), url, **kwargs)
assert r.status_code == 404, (method, url, r.status_code, r.text)
detail = r.json()["detail"]
assert "可能已经被删了" in detail, (method, url, detail)
assert gone not in detail, f"把 id 原样念给用户听没用:{detail}"