"""会话列表认得每个会话的最后一轮停在哪;轮次的可信度元数据有自己的一列。 以前左栏只能给「这次打开页面以来取回过内存」的会话标状态。没打开过的会话, 哪怕最后一轮正停在审批上、或者已经失败,列表里也和正常完成的长得一样—— 人得一个个点进去才知道哪里在等他。 元数据(出具档位、运行类别、未查库、结局、耗时…)以前挂在 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, end_reason: str | None = None) -> str: async with SessionLocal() as session: run = Run(workflow_name="状态测试", status=status, end_reason=end_reason) 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_a_rejected_turn_is_listed_as_rejected_not_cancelled(client): """被审批人驳回结束的一轮(运行 cancelled + end_reason=rejected,05-已知遗留 (a)):列表说「被驳回」,不和手动停止混成 cancelled——发问的人不一定知道被驳回了,以前驳回记成失败,列表标的是「失败」。认法两条:前端落库时记下的 meta.rejected,和页面关掉、轮次还停在 running 时运行的 end_reason。手动停止照旧是 cancelled。""" cases: dict[str, tuple[str, str | None]] = {} conv = await _conversation(client, "驳回:前端落库了") run = await _run("cancelled", end_reason="rejected") await _turn(client, conv, run_id=run, status="error", error="已取消", meta={"v": 1, "runId": run, "outcome": "cancelled", "rejected": {"sub": "张工 驳回了「付款」"}}) cases[conv] = ("rejected", run) conv = await _conversation(client, "驳回:页面关了") run = await _run("cancelled", end_reason="rejected") await _turn(client, conv, run_id=run) cases[conv] = ("rejected", run) conv = await _conversation(client, "手动停止:页面关了") run = await _run("cancelled", end_reason="stopped") await _turn(client, conv, run_id=run) cases[conv] = ("cancelled", run) conv = await _conversation(client, "手动停止:前端落库了") run = await _run("cancelled", end_reason="stopped") await _turn(client, conv, run_id=run, status="error", error="已取消", meta={"v": 1, "runId": run, "outcome": "cancelled"}) cases[conv] = ("cancelled", run) listed = await _listed(client) assert {cid: (listed[cid]["last_status"], listed[cid]["last_run_id"]) for cid in cases} == cases detail = (await client.get(f"/api/conversations/{next(iter(cases))}")).json() assert detail["last_status"] == "rejected" 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]