Files
AgentLab/backend/tests/test_conversation_status.py
T
ThearchyHelios b418c0d951 最终验收:148 项逐条复核(原审查 142 条 + 平台问题 6 条),返工后 145 项解决
四组验收员拿审查原文在沙箱里逐条复现:133 项已解决、15 项部分解决、没有未解决或回退的;
另发现 10 个新问题。按归属返工后,剩 3 项低严重度:MCP / 自定义工具的危险标记(待用户拍板)、
选中环与运行态同色(需跨三处协调改)、「接着跑」这个说法是否更换(多处引用原文,暂留)。

这次返工改了
- 右栏:老运行没有 active_ms 时执行时长改按航迹算,不再显示最后一段的「22 ms」;循环出口行不再差一;
  取消在一轮中途时泳道脚注不写「0 ms」;分支、循环出口显示 case 的说明而不是 key;
  展开已完成轮次不再误报「N 条新进展」;旧数据里已完成的模型调用不再一律标成「已取消」
- 后端:跳过事件带节点名和类型;restore 版本时把已发布 / 受管退回草稿;只是打开旧会话不再把它顶到最前、
  标成「刚刚」;协作团队全程没有成员交付成果、调度者却判完成时按失败或降档处理,不再当成功交付
- 画布:循环体有「循环体 · 最多 N 轮」的底框,出口标签不再被自己的走线穿过;运行中校验角标退让;
  768 宽下工具栏不溢出;运行被拦时点名是哪个节点;正式运行弹层写明以谁的名义发起
- 问数据:离线时能力条不把「没取回来」说成 0;窄屏下会话列表收起;check-chat 在零点前后不再误报
- 公共层:FastAPI 422 校验错误翻成中文,不再把英文原文透给用户

验证(沙箱):tsc 干净,pytest 878 passed(两遍),check-all 15 项检查全绿。
2026-09-28 02:42:40 +08:00

316 lines
13 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.
"""会话列表认得每个会话的最后一轮停在哪;轮次的可信度元数据有自己的一列。
以前左栏只能给「这次打开页面以来取回过内存」的会话标状态。没打开过的会话,
哪怕最后一轮正停在审批上、或者已经失败,列表里也和正常完成的长得一样——
人得一个个点进去才知道哪里在等他。
元数据(出具档位、运行类别、未查库、结局、耗时…)以前挂在 review.meta 下,
于是没复核过的轮次 review 也不为空,「没复核过」和「复核过、没问题」分不开。
"""
from __future__ import annotations
from datetime import timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal, utcnow
from app.db.models import Approval, Conversation, ConversationTurn, Run
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
async def _conversation(client, title: str) -> str:
r = await client.post("/api/conversations", json={"kind": "chat", "title": title})
assert r.status_code == 201, r.text
return r.json()["id"]
async def _turn(client, conv_id: str, question: str = "订单数多少", **patch) -> dict:
r = await client.post(f"/api/conversations/{conv_id}/turns", json={"question": question})
assert r.status_code == 201, r.text
turn = r.json()
if patch:
r = await client.patch(f"/api/conversations/{conv_id}/turns/{turn['id']}", json=patch)
assert r.status_code == 200, r.text
turn = r.json()
return turn
async def _run(status: str, *, pending: bool = False) -> str:
async with SessionLocal() as session:
run = Run(workflow_name="状态测试", status=status)
session.add(run)
await session.flush()
if pending:
session.add(Approval(run_id=run.id, node_id="gate", status="pending"))
await session.commit()
return run.id
async def _listed(client) -> dict[str, dict]:
r = await client.get("/api/conversations")
assert r.status_code == 200, r.text
return {c["id"]: c for c in r.json()}
# --------------------------------------------------------------------------
# REQ-1:meta 自己一列
# --------------------------------------------------------------------------
META = {"v": 1, "runClass": "exploratory", "noQuery": True, "ms": 8200, "queries": 0}
async def test_turn_meta_is_stored_and_read_back_on_its_own(client):
conv = await _conversation(client, "元数据")
turn = await _turn(client, conv, meta=META, status="done", answer="12 家")
assert turn["meta"] == META
assert turn["review"] is None, "只写了 meta,review 得还是「没复核过」"
detail = (await client.get(f"/api/conversations/{conv}")).json()
assert detail["turns"][0]["meta"] == META
async def test_patching_review_and_meta_do_not_overwrite_each_other(client):
conv = await _conversation(client, "互不覆盖")
turn = await _turn(client, conv, meta=META)
review = {"verdict": "annotated", "note": "检索降级"}
r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}", json={"review": review})
assert r.json()["meta"] == META
r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}",
json={"meta": {**META, "ms": 9000}})
assert r.json()["review"] == review
assert r.json()["meta"]["ms"] == 9000
async def test_old_turns_with_meta_under_review_still_read_as_meta(client):
"""迁移之前写进去的:meta 挂在 review 下。读的时候由后端兜底,前端只认 turn.meta。"""
conv = await _conversation(client, "老数据")
legacy = {"verdict": "ok", "meta": {"v": 1, "outcome": "cancelled"}}
turn = await _turn(client, conv, review=legacy, status="error", error="已取消")
assert turn["meta"] == {"v": 1, "outcome": "cancelled"}
# 新列一旦写过就以它为准
r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}", json={"meta": META})
assert r.json()["meta"] == META
# --------------------------------------------------------------------------
# REQ-2:列表里的最后一轮状态
# --------------------------------------------------------------------------
async def test_the_list_says_where_each_conversation_last_stopped(client):
cases: dict[str, tuple[str, str | None]] = {}
conv = await _conversation(client, "完成")
run = await _run("succeeded")
await _turn(client, conv, run_id=run, status="done", answer="12")
cases[conv] = ("done", run)
conv = await _conversation(client, "失败")
run = await _run("failed")
await _turn(client, conv, run_id=run, status="error", error="查询超时")
cases[conv] = ("error", run)
conv = await _conversation(client, "取消")
await _turn(client, conv, status="error", error="已取消")
cases[conv] = ("cancelled", None)
conv = await _conversation(client, "重启挂起")
run = await _run("interrupted")
await _turn(client, conv, run_id=run, status="error", error="服务重启,这一轮中断了",
meta={"v": 1, "outcome": "suspended", "runId": run})
cases[conv] = ("suspended", run)
conv = await _conversation(client, "在跑")
run = await _run("running")
await _turn(client, conv, run_id=run)
cases[conv] = ("running", run)
conv = await _conversation(client, "等审批")
run = await _run("interrupted", pending=True)
await _turn(client, conv, run_id=run)
cases[conv] = ("waiting", run)
conv = await _conversation(client, "停着没审批")
run = await _run("interrupted")
await _turn(client, conv, run_id=run)
cases[conv] = ("suspended", run)
conv = await _conversation(client, "页面关了但跑完了")
run = await _run("succeeded")
await _turn(client, conv, run_id=run)
cases[conv] = ("done", run)
conv = await _conversation(client, "页面关了而且失败了")
run = await _run("failed")
await _turn(client, conv, run_id=run)
cases[conv] = ("error", run)
conv = await _conversation(client, "正在建图")
await _turn(client, conv)
cases[conv] = ("running", None)
listed = await _listed(client)
got = {cid: (listed[cid]["last_status"], listed[cid]["last_run_id"]) for cid in cases}
assert got == cases
async def test_only_the_last_turn_counts(client):
conv = await _conversation(client, "后来好了")
await _turn(client, conv, status="error", error="查询超时")
run = await _run("succeeded")
await _turn(client, conv, run_id=run, status="done", answer="好了")
assert (await _listed(client))[conv]["last_status"] == "done"
async def test_a_build_abandoned_long_ago_is_not_shown_as_running(client):
"""建图是跟着浏览器那条流走的:页面关了就没了,轮次却一直停在 running。"""
conv = await _conversation(client, "建到一半关了")
turn = await _turn(client, conv)
async with SessionLocal() as session:
row = await session.get(ConversationTurn, turn["id"])
row.updated_at = utcnow() - timedelta(hours=2)
await session.commit()
assert (await _listed(client))[conv]["last_status"] == "error"
async def test_an_empty_conversation_has_no_status(client):
conv = await _conversation(client, "空的")
item = (await _listed(client))[conv]
assert item["last_status"] is None and item["last_run_id"] is None
async def test_the_detail_carries_the_same_status(client):
conv = await _conversation(client, "详情也带")
run = await _run("interrupted", pending=True)
await _turn(client, conv, run_id=run)
detail = (await client.get(f"/api/conversations/{conv}")).json()
assert (detail["last_status"], detail["last_run_id"]) == ("waiting", run)
# --------------------------------------------------------------------------
# 重试之后:meta.runId 才是这一轮现在的运行
# --------------------------------------------------------------------------
async def test_a_retried_turn_reports_the_new_run_not_the_old_one(client):
"""重试会换一次运行,可 run_id 列清不掉(TurnPatch 的 None 表示「不改」),
还指着上一次失败的运行;前端写的 meta.runId 才是权威。列表得按它报,
不然重试正在建图时,左栏还挂着上一次的「失败」。"""
conv = await _conversation(client, "重试了")
old = await _run("failed")
turn = await _turn(client, conv, run_id=old, status="error", error="查询超时",
meta={"v": 1, "runId": old})
# 重试:重新建图,还没有运行
r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}",
json={"status": "running", "meta": {"v": 1, "runId": None}})
assert r.status_code == 200
item = (await _listed(client))[conv]
assert (item["last_status"], item["last_run_id"]) == ("running", None)
# 重试出来的运行起来了,还在跑
new = await _run("running")
await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}",
json={"meta": {"v": 1, "runId": new}})
item = (await _listed(client))[conv]
assert (item["last_status"], item["last_run_id"]) == ("running", new)
detail = (await client.get(f"/api/conversations/{conv}")).json()
assert (detail["last_status"], detail["last_run_id"]) == ("running", new)
async def test_old_turns_without_run_id_in_meta_still_use_the_column(client):
conv = await _conversation(client, "老轮次")
run = await _run("interrupted", pending=True)
await _turn(client, conv, run_id=run, meta={"v": 1, "ms": 1200})
item = (await _listed(client))[conv]
assert (item["last_status"], item["last_run_id"]) == ("waiting", run)
async def test_turns_carry_updated_at_for_the_same_staleness_clock(client):
"""前端判断「建图断了」要和后端用同一只钟:后端按 updated_at 算。"""
conv = await _conversation(client, "带时间")
turn = await _turn(client, conv)
assert turn.get("updated_at"), turn
detail = (await client.get(f"/api/conversations/{conv}")).json()
assert detail["turns"][0]["updated_at"]
# --------------------------------------------------------------------------
# 活跃时间:只是打开一个旧会话,不算在这里做了事
# --------------------------------------------------------------------------
async def _age(conv_id: str, days: int) -> None:
async with SessionLocal() as session:
row = await session.get(Conversation, conv_id)
row.last_active_at = utcnow() - timedelta(days=days)
await session.commit()
async def _order(client, *ids: str) -> list[str]:
r = await client.get("/api/conversations")
return [c["id"] for c in r.json() if c["id"] in ids]
async def test_catching_up_on_open_does_not_bump_the_conversation(client):
"""打开会话时前端会回源核对:补全截断的答案、记下运行已删、把断掉的建图写回
error、补交付错过的结果。这些都不是人在这里做了事——以前每一次都把
last_active_at 改成现在,旧会话一打开就跳到列表最上面、写着「刚刚」。"""
old = await _conversation(client, "上周的")
turn = await _turn(client, old, run_id=await _run("succeeded"), status="done", answer="十二")
recent = await _conversation(client, "昨天的")
await _turn(client, recent)
await _age(old, 7)
await _age(recent, 1)
before = (await _listed(client))[old]["last_active_at"]
assert await _order(client, old, recent) == [recent, old]
url = f"/api/conversations/{old}/turns/{turn['id']}"
for body in (
{"meta": {"v": 1, "clipped": "lost"}, "review": {}}, # 运行删了、答案补不回来
{"answer": "十二(完整版)", "meta": {"v": 1}, "review": {}}, # 截断的答案换成完整的
{"status": "error", "error": "上次没有跑完", "meta": {"v": 1}, "review": {}},
{"status": "done", "answer": "补交付", "meta": {"v": 1}, "review": {}},
):
r = await client.patch(url, json=body)
assert r.status_code == 200, r.text
assert (await _listed(client))[old]["last_active_at"] == before, body
assert await _order(client, old, recent) == [recent, old]
@pytest.mark.parametrize("body", [
{"run_id": "RUN", "status": "running"}, # 按下运行
{"status": "running", "error": "", "answer": ""}, # 重试这一轮
{"question": "换个问法"},
])
async def test_starting_something_in_a_turn_still_bumps_it(client, body):
old = await _conversation(client, "上周的")
turn = await _turn(client, old)
recent = await _conversation(client, "昨天的")
await _turn(client, recent)
await _age(old, 7)
await _age(recent, 1)
if body.get("run_id") == "RUN":
body = {**body, "run_id": await _run("running")}
r = await client.patch(f"/api/conversations/{old}/turns/{turn['id']}", json=body)
assert r.status_code == 200, r.text
assert await _order(client, old, recent) == [old, recent]
async def test_a_new_turn_bumps_it(client):
old = await _conversation(client, "上周的")
await _turn(client, old)
recent = await _conversation(client, "昨天的")
await _turn(client, recent)
await _age(old, 7)
await _age(recent, 1)
await _turn(client, old, "接着问")
assert await _order(client, old, recent) == [old, recent]