mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:11:58 +00:00
b418c0d951
四组验收员拿审查原文在沙箱里逐条复现: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 项检查全绿。
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""会话列表认得每个会话的最后一轮停在哪;轮次的可信度元数据有自己的一列。
|
||
|
||
以前左栏只能给「这次打开页面以来取回过内存」的会话标状态。没打开过的会话,
|
||
哪怕最后一轮正停在审批上、或者已经失败,列表里也和正常完成的长得一样——
|
||
人得一个个点进去才知道哪里在等他。
|
||
|
||
元数据(出具档位、运行类别、未查库、结局、耗时…)以前挂在 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]
|