"""AgentService 编排测试:repo 层打桩 + 假网关/假计费,不连库不发网。""" from __future__ import annotations from decimal import Decimal from typing import Any import pytest from packages.agent import repo from packages.agent.service import AgentService from packages.billing.service import Reservation from packages.llm.types import Completion, Scene, Usage from packages.shared.errors import NotFoundError from packages.shared.models import AgConversation USER_ID = 7001 CONVERSATION_ID = 9001 class FakeSession: def __init__(self) -> None: self.commits = 0 self.rollbacks = 0 async def commit(self) -> None: self.commits += 1 async def rollback(self) -> None: self.rollbacks += 1 class FakeGateway: def __init__(self) -> None: self.calls: list[dict[str, Any]] = [] async def complete(self, **kwargs: Any) -> Completion: self.calls.append(kwargs) return Completion( text="可以试试白衬衫加深蓝直筒裤。", scene=Scene.AGENT_CHAT, provider="deepseek", model="deepseek-v4-flash", prompt_id="agent_chat", prompt_version="v1", usage=Usage(tokens_in=100, tokens_out=50, tokens_cached=0, tokens_reasoning=0), latency_ms=800, cost_cents=Decimal("0.05"), call_id=555, ) class FailingGateway(FakeGateway): async def complete(self, **kwargs: Any) -> Completion: raise RuntimeError("provider exploded") class FakeBilling: def __init__(self) -> None: self.refunds: list[str] = [] async def reserve(self, **kwargs: Any) -> Reservation: return Reservation( tx_id=1, user_id=kwargs["user_id"], quota_key=kwargs["quota_key"], period_key="day:2026-09-11", amount=1, remaining=9, idempotency_key="k", ) async def commit(self, reservation: Reservation) -> None: pass async def refund(self, reservation: Reservation, *, reason: str) -> None: self.refunds.append(reason) @pytest.fixture def store(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: """repo 层全打桩:会话与消息落进内存 dict,断言编排逻辑而非 SQL。""" state: dict[str, Any] = { "conversations": { CONVERSATION_ID: AgConversation( conversation_id=str(CONVERSATION_ID), user_id=USER_ID, title="", message_count=0, last_message_at=None, ) }, "messages": [], "history": [], "next_id": 100, } def _next_id() -> int: state["next_id"] += 1 return state["next_id"] async def fake_create(session: Any, *, conversation_id: int, user_id: int) -> AgConversation: conv = AgConversation( conversation_id=conversation_id, user_id=user_id, title="", message_count=0, last_message_at=None, ) state["conversations"][conversation_id] = conv return conv async def fake_get(session: Any, conversation_id: int) -> AgConversation | None: return state["conversations"].get(conversation_id) async def fake_recent(session: Any, *, conversation_id: int, limit: int) -> list[Any]: return state["history"] async def fake_append( session: Any, *, message_id: int, conversation: AgConversation, role: str, content: str, **kwargs: Any, ) -> Any: state["messages"].append({"role": role, "content": content, **kwargs}) conversation.message_count += 1 if not conversation.title and role == "user": conversation.title = content[:20] return None monkeypatch.setattr(repo, "create_conversation", fake_create) monkeypatch.setattr(repo, "get_conversation", fake_get) monkeypatch.setattr(repo, "list_recent_messages", fake_recent) monkeypatch.setattr(repo, "append_message", fake_append) monkeypatch.setattr("packages.agent.service.next_id", _next_id) return state def _service(store: dict[str, Any], **overrides: Any) -> AgentService: kwargs: dict[str, Any] = { "gateway": FakeGateway(), "billing": FakeBilling(), "profile_provider": None, "history_turns": 10, } kwargs.update(overrides) return AgentService(FakeSession(), **kwargs) # type: ignore[arg-type] class TestChat: async def test_implicit_conversation_created_and_messages_persisted( self, store: dict[str, Any] ) -> None: service = _service(store) result = await service.chat(user_id=USER_ID, message="面试怎么穿", request_key="k1") # 隐式建会话,响应带回会话 ID assert result.conversation_id in store["conversations"] assert result.conversation_id != CONVERSATION_ID # 用户消息 + assistant 消息双行落库,assistant 行带复盘字段 assert [m["role"] for m in store["messages"]] == ["user", "assistant"] assistant = store["messages"][1] assert assistant["call_id"] == 555 assert assistant["model"] == "deepseek-v4-flash" assert assistant["cost_cents"] == Decimal("0.05") # 会话标题取首条用户消息 conv = store["conversations"][result.conversation_id] assert conv.title == "面试怎么穿" assert result.quota_remaining == 9 async def test_existing_conversation_carries_history(self, store: dict[str, Any]) -> None: from packages.llm.types import Message conv = store["conversations"][CONVERSATION_ID] conv.message_count = 2 store["history"] = [ _msg("user", "之前的问题"), _msg("assistant", "之前的回答"), ] service = _service(store) await service.chat( user_id=USER_ID, message="那约会呢", request_key="k2", conversation_id=str(CONVERSATION_ID), ) sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined] assert [m.content for m in sent] == ["之前的问题", "之前的回答"] assert isinstance(sent[0], Message) async def test_profile_injected_as_leading_system_message(self, store: dict[str, Any]) -> None: async def profile_provider(user_id: int) -> str | None: assert user_id == USER_ID return '{"dimensions":[{"key":"body_shape","value":"梨形"}]}' service = _service(store, profile_provider=profile_provider) await service.chat( user_id=USER_ID, message="推荐裤子", request_key="k3", conversation_id=str(CONVERSATION_ID), ) sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined] assert sent[0].role == "system" assert "梨形" in sent[0].content async def test_profile_failure_degrades_silently(self, store: dict[str, Any]) -> None: async def broken_provider(user_id: int) -> str | None: raise RuntimeError("report table down") service = _service(store, profile_provider=broken_provider) result = await service.chat( user_id=USER_ID, message="hi", request_key="k4", conversation_id=str(CONVERSATION_ID), ) assert result.reply # 画像失败不阻断对话 sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined] assert all(m.role != "system" for m in sent) async def test_other_users_conversation_not_found(self, store: dict[str, Any]) -> None: """他人会话与不存在同口径 404,不暴露存在性。""" service = _service(store) with pytest.raises(NotFoundError): await service.chat( user_id=9999, message="hi", request_key="k5", conversation_id=str(CONVERSATION_ID), ) async def test_llm_failure_refunds_and_persists_nothing(self, store: dict[str, Any]) -> None: billing = FakeBilling() service = _service(store, gateway=FailingGateway(), billing=billing) with pytest.raises(RuntimeError): await service.chat( user_id=USER_ID, message="hi", request_key="k6", conversation_id=str(CONVERSATION_ID), ) assert billing.refunds == ["RuntimeError"] assert store["messages"] == [] async def test_large_snowflake_id_as_string(self, store: dict[str, Any]) -> None: """超 2^53 的雪花 ID 以字符串传入,无精度丢失地落到归属校验。""" big = 223843017600012288 # > 2**53,JS Number 无法精确表示 store["conversations"][big] = AgConversation( conversation_id=big, user_id=USER_ID, title="", message_count=0, last_message_at=None, ) service = _service(store) result = await service.chat( user_id=USER_ID, message="hi", request_key="k7", conversation_id=str(big), ) assert result.conversation_id == big async def test_invalid_conversation_id_same_404(self, store: dict[str, Any]) -> None: """非法字符串与「不存在」同口径 40400,不给遍历者额外信号。""" service = _service(store) with pytest.raises(NotFoundError): await service.chat( user_id=USER_ID, message="hi", request_key="k8", conversation_id="not-a-number", ) async def test_context_build_failure_refunds( self, store: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """预扣 commit 之后,上下文组装(历史查询)抛异常也必须退还额度。""" async def broken_recent(session: Any, *, conversation_id: int, limit: int) -> list[Any]: raise RuntimeError("db hiccup") monkeypatch.setattr(repo, "list_recent_messages", broken_recent) conv = store["conversations"][CONVERSATION_ID] conv.message_count = 2 # 触发历史查询路径 billing = FakeBilling() service = _service(store, billing=billing) with pytest.raises(RuntimeError): await service.chat( user_id=USER_ID, message="hi", request_key="k9", conversation_id=str(CONVERSATION_ID), ) assert billing.refunds == ["RuntimeError"] assert store["messages"] == [] class TestConversationApis: async def test_create_and_get_owned(self, store: dict[str, Any]) -> None: service = _service(store) conversation = await service.create_conversation(user_id=USER_ID) assert conversation.user_id == USER_ID owned = await service.get_owned_conversation( user_id=USER_ID, conversation_id=conversation.conversation_id ) assert owned.conversation_id == conversation.conversation_id with pytest.raises(NotFoundError): await service.get_owned_conversation(user_id=USER_ID, conversation_id=404) async def test_list_conversations_scoped_to_user( self, store: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: async def fake_list(session: Any, *, user_id: int, limit: int = 20) -> list[Any]: return [c for c in store["conversations"].values() if c.user_id == user_id] monkeypatch.setattr(repo, "list_conversations", fake_list) service = _service(store) mine = await service.list_conversations(user_id=USER_ID) other = await service.list_conversations(user_id=9999) assert len(mine) == 1 assert other == [] def _msg(role: str, content: str) -> Any: from packages.llm.types import Message return Message(role=role, content=content)