Files
AgentLab/backend/tests/test_conversation_status.py
T
ThearchyHelios b6cb5816db 问数据:被驳回的那次尝试不写「已手动停止」,会话列表标「被驳回」
为什么:一轮的运行被审批人驳回之后点「重新运行本轮」,「之前的尝试」那一行写「已手动停止」,用户其实没有停过它。会话
列表的 last_status 把被驳回的一轮归成 cancelled,左栏不标——在等审批的那一问被驳回之后,看起来像是答完了(以前驳回
记成失败,左栏标的是「失败」)。

做了什么:
- store/chat 的 attemptOf:被驳回的那次记 outcome=rejected,摘要写「被驳回:张工 驳回了「查数」:…」;老留档里记成
  cancelled 的不改写。ChatPage 的尝试徽标认 rejected。
- api/conversations 的 last_status:前端落库时 meta 里记着 rejected 的、页面关掉没落库而运行是 cancelled +
  end_reason=rejected 的,都记 rejected;手动停止照旧 cancelled。types 的 last_status 加上 rejected。
- 左栏:打开过的看最后一轮(相位 cancelled 且带着驳回),没打开过的看 last_status,都标「被驳回」(不画异常色)。

测试:test_conversation_status 加一条(两种认法的被驳回、两种手动停止),改之前失败;check-chat 加一节被驳回(刷新后
认得出、补救区的话、左栏两种、重新运行之后的尝试记录),把前端改动退回去回跑,那一节 4 项不通过,改完通过。
2026-10-16 12:46:36 +08:00

349 lines
15 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, 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]