2026-09-10 15:55:54 +08:00
|
|
|
|
"""`RunQueryService` 契约测试(全部使用替身,不连数据库)。
|
|
|
|
|
|
|
|
|
|
|
|
关注三条对客户端与安全都重要的语义:
|
|
|
|
|
|
1. 运行不存在与越权访问**返回同一个 404**——不能靠错误码区分"存在但无权";
|
|
|
|
|
|
2. 未成功的运行**不返回任何结果内容**(`result` 为 None),避免未提交结果外泄;
|
|
|
|
|
|
3. 快照里的时间统一以 `...Z` 结尾(UTC),以及 `watch` 的心跳与终态退出行为。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, replace
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
|
|
|
|
|
from app.core.errors import RunNotFoundError
|
|
|
|
|
|
from app.service.run_query_service import RunQueryService, RunSnapshot
|
|
|
|
|
|
|
|
|
|
|
|
CONTEXT = RequestContext(user_id="9001", trace_id="trace-1")
|
|
|
|
|
|
NOW = datetime(2026, 9, 10, 12, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class FakeRun:
|
|
|
|
|
|
run_id: str = "run-1"
|
|
|
|
|
|
trace_id: str = "trace-1"
|
|
|
|
|
|
status: str = "running"
|
|
|
|
|
|
agent_type: str = "customer_service"
|
|
|
|
|
|
session_id: str = "session-1"
|
|
|
|
|
|
error_code: str | None = None
|
|
|
|
|
|
created_at: datetime = NOW
|
|
|
|
|
|
completed_at: datetime | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class FakeMessage:
|
|
|
|
|
|
content: str = "稳健型"
|
|
|
|
|
|
tool_calls: Any = None
|
|
|
|
|
|
intent: str | None = "general"
|
|
|
|
|
|
confidence: Any = None
|
|
|
|
|
|
source_references: Any = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeRepository:
|
|
|
|
|
|
def __init__(self, rows: tuple[Any, Any] | None) -> None:
|
|
|
|
|
|
self.rows = rows
|
|
|
|
|
|
|
|
|
|
|
|
async def run_result(self, run_id: str, user_id: int) -> tuple[Any, Any] | None:
|
|
|
|
|
|
del run_id, user_id
|
|
|
|
|
|
return self.rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeSession:
|
|
|
|
|
|
async def __aenter__(self) -> "FakeSession":
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, *exc: object) -> bool:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def patch_repository(monkeypatch: pytest.MonkeyPatch, rows: tuple[Any, Any] | None) -> None:
|
|
|
|
|
|
monkeypatch.setattr("app.service.run_query_service.SessionFactory", FakeSession)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.service.run_query_service.ConversationRepository",
|
|
|
|
|
|
lambda _session: FakeRepository(rows),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_unknown_run_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
"""不存在与越权必须是同一个错误,否则可以据此探测资源是否存在。"""
|
|
|
|
|
|
patch_repository(monkeypatch, None)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RunNotFoundError) as excinfo:
|
|
|
|
|
|
await RunQueryService().get("run-x", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert excinfo.value.code == "RUN_NOT_FOUND"
|
|
|
|
|
|
assert excinfo.value.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_running_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
patch_repository(monkeypatch, (FakeRun(status="running"), None))
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.status == "running"
|
|
|
|
|
|
assert snapshot.result is None
|
|
|
|
|
|
assert snapshot.completed_at is None
|
|
|
|
|
|
assert snapshot.created_at.endswith("Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_succeeded_run_exposes_result_with_string_confidence(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
message = FakeMessage(
|
|
|
|
|
|
content="稳健型",
|
|
|
|
|
|
tool_calls={"calls": []},
|
|
|
|
|
|
confidence=Decimal("0.9000"),
|
|
|
|
|
|
source_references=[{"source_type": "memory"}],
|
|
|
|
|
|
)
|
|
|
|
|
|
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.result is not None
|
|
|
|
|
|
assert snapshot.result["content"] == "稳健型"
|
|
|
|
|
|
# DECIMAL(5,4) 经驱动回来是 Decimal,接口层统一转字符串避免浮点精度歧义。
|
|
|
|
|
|
assert snapshot.result["confidence"] == "0.9000"
|
|
|
|
|
|
assert snapshot.result["source_references"] == [{"source_type": "memory"}]
|
|
|
|
|
|
assert snapshot.completed_at is not None and snapshot.completed_at.endswith("Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
"""即使库里有结果消息,失败运行也不得把它当作成功结果返回。"""
|
|
|
|
|
|
patch_repository(
|
|
|
|
|
|
monkeypatch, (FakeRun(status="failed", error_code="AGENT_INTERNAL_ERROR"), FakeMessage())
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.result is None
|
|
|
|
|
|
assert snapshot.error_code == "AGENT_INTERNAL_ERROR"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 21:17:47 +08:00
|
|
|
|
# --- `docs/05` §6.3 要求的 `transfer_required` / `transfer_reason` 出参 -----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_result_exposes_transfer_marker_when_transferred(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""兜底/转人工分支必须把标记**出参**给客户端。
|
|
|
|
|
|
|
|
|
|
|
|
为什么这条重要:前端判断"这轮要不要转人工"此前只能靠**猜正文里有没有兜底话术开头**
|
|
|
|
|
|
(`docs/24` 自称权宜之计)。`docs/05` §6.3 一直规定 `result` 里有这两个字段,
|
|
|
|
|
|
但此前既没落库也没出参 —— 这个用例把"契约已兑现"钉住。
|
|
|
|
|
|
"""
|
|
|
|
|
|
message = FakeMessage(
|
|
|
|
|
|
content="抱歉,这个问题我暂时无法给出准确答复…",
|
|
|
|
|
|
tool_calls={"calls": [], "transfer_required": True,
|
|
|
|
|
|
"transfer_reason": "置信度不足:score=0.571 gap=0.004"},
|
|
|
|
|
|
)
|
|
|
|
|
|
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.result is not None
|
|
|
|
|
|
assert snapshot.result["transfer_required"] is True
|
|
|
|
|
|
assert snapshot.result["transfer_reason"] == "置信度不足:score=0.571 gap=0.004"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_result_transfer_marker_defaults_to_false(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""正常回答:标记为 False、原因为 None(不能因为缺键就返回 None 让客户端混淆)。"""
|
|
|
|
|
|
message = FakeMessage(content="交易日 15:00 前提交…",
|
|
|
|
|
|
tool_calls={"calls": [], "transfer_required": False,
|
|
|
|
|
|
"transfer_reason": None})
|
|
|
|
|
|
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.result is not None
|
|
|
|
|
|
assert snapshot.result["transfer_required"] is False
|
|
|
|
|
|
assert snapshot.result["transfer_reason"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_result_tolerates_legacy_tool_calls_shape(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""**兼容历史行**:本字段上线前落库的 `tool_calls` 里没有这两个键。
|
|
|
|
|
|
|
|
|
|
|
|
那种行的 `tool_calls` 可能就是裸列表,甚至为 None。读取时必须按 False/None 处理,
|
|
|
|
|
|
**不得抛异常、也不得凭正文内容去猜**——猜错方向会让"不需要转人工"的答复被标成转人工。
|
|
|
|
|
|
"""
|
|
|
|
|
|
for legacy in ({"calls": []}, [], None):
|
|
|
|
|
|
message = FakeMessage(content="稳健型", tool_calls=legacy)
|
|
|
|
|
|
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
|
|
|
|
|
|
|
|
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
|
|
|
|
|
|
|
|
assert snapshot.result is not None, legacy
|
|
|
|
|
|
assert snapshot.result["transfer_required"] is False, legacy
|
|
|
|
|
|
assert snapshot.result["transfer_reason"] is None, legacy
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 15:55:54 +08:00
|
|
|
|
def terminal_snapshot(status: str = "succeeded") -> RunSnapshot:
|
|
|
|
|
|
return RunSnapshot(
|
|
|
|
|
|
run_id="run-1",
|
|
|
|
|
|
trace_id="trace-1",
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
agent_type="customer_service",
|
|
|
|
|
|
session_id="session-1",
|
|
|
|
|
|
result={"content": "ok"},
|
|
|
|
|
|
error_code=None,
|
|
|
|
|
|
created_at="2026-09-10T12:00:00Z",
|
|
|
|
|
|
completed_at="2026-09-10T12:00:01Z",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_watch_stops_immediately_on_terminal_snapshot(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""终态快照只发一次且不再轮询——断流重连也走这条路径。"""
|
|
|
|
|
|
calls = {"count": 0}
|
|
|
|
|
|
|
|
|
|
|
|
async def never_called(self: RunQueryService, run_id: str, context: RequestContext) -> Any:
|
|
|
|
|
|
calls["count"] += 1
|
|
|
|
|
|
raise AssertionError("终态不应继续轮询数据库")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(RunQueryService, "get", never_called)
|
|
|
|
|
|
initial = terminal_snapshot()
|
|
|
|
|
|
|
|
|
|
|
|
events = [snapshot async for snapshot in RunQueryService().watch(initial, CONTEXT)]
|
|
|
|
|
|
|
|
|
|
|
|
assert events == [initial]
|
|
|
|
|
|
assert calls["count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_watch_emits_heartbeat_between_polls(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.service.run_query_service.get_settings",
|
|
|
|
|
|
lambda: SimpleNamespace(sse_max_connection_seconds=30, sse_heartbeat_seconds=0),
|
|
|
|
|
|
)
|
|
|
|
|
|
polls = {"count": 0}
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_get(self: RunQueryService, run_id: str, context: RequestContext) -> RunSnapshot:
|
|
|
|
|
|
del self, run_id, context
|
|
|
|
|
|
polls["count"] += 1
|
|
|
|
|
|
return replace(terminal_snapshot(), status="succeeded")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(RunQueryService, "get", fake_get)
|
|
|
|
|
|
initial = replace(terminal_snapshot(), status="running", result=None, completed_at=None)
|
|
|
|
|
|
|
|
|
|
|
|
events = [snapshot async for snapshot in RunQueryService().watch(initial, CONTEXT)]
|
|
|
|
|
|
|
|
|
|
|
|
# 序列:非终态快照 → 心跳(None) → 轮询得到的终态快照
|
|
|
|
|
|
assert [event is None for event in events] == [False, True, False]
|
|
|
|
|
|
assert events[0] is initial
|
|
|
|
|
|
assert events[2] is not None and events[2].status == "succeeded"
|
|
|
|
|
|
assert polls["count"] == 1
|