"""对话 temporal_context 注入测试(辰正 §8.1)。""" from __future__ import annotations from collections.abc import AsyncGenerator from typing import Any import pytest from httpx import ASGITransport, AsyncClient from app.application.agent.runtime import AgentTurnResult from app.application.chat.send_message import _contract_system_messages from app.main import app from app.modules.runtime.contract import RuntimeContract def _make_contract(temporal: str | None) -> RuntimeContract: return RuntimeContract( character_id="char_x", session_id="sess_x", run_id="run_x", model_hint="default", system_instructions=["base"], personality_summary="人格X", memory_context="记忆Y", temporal_context=temporal, trace_id="trace_x", ) def test_contract_system_messages_includes_temporal_context(): messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。")) assert any("子时·深夜" in m for m in messages) def test_contract_system_messages_no_temporal_context_when_none(): messages = _contract_system_messages(_make_contract(None)) assert all("子时" not in m for m in messages) def test_contract_system_messages_temporal_after_memory_before_safety(): """注入位置:记忆之后、安全约束之前(设计 §8.1)。""" contract = RuntimeContract( character_id="c", session_id="s", run_id="r", model_hint="m", system_instructions=["base"], memory_context="MEMORY_MARKER", temporal_context="TEMPORAL_MARKER", safety_constraints=["SAFETY_MARKER"], trace_id="t", ) messages = _contract_system_messages(contract) joined = "\n".join(messages) assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER") assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER") # --------------------------------------------------------------------------- # §12 降级路径守护(设计 §12:辰正永不阻塞对话) # 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成 # 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞 # --------------------------------------------------------------------------- async def _create_session(client: AsyncClient) -> str: resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"}) assert resp.status_code == 200 return str(resp.json()["session_id"]) @pytest.fixture async def temporal_client( auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch ) -> AsyncGenerator[AsyncClient, None]: """HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。 复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message 全链路),让降级路径在生产代码路径中被真实驱动。 """ captured: dict[str, Any] = {} async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult: captured["messages"] = messages return AgentTurnResult(content="好的。", reasoning="") monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn) transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test", headers=auth_headers ) as http_client: http_client._temporal_captured = captured # type: ignore[attr-defined] yield http_client def _system_text(messages: list[Any]) -> str: return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system") @pytest.mark.asyncio async def test_temporal_context_skipped_when_event_store_is_none( temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。""" from app.application.chat.send_message import drain_pending_streams # 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径) # 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时 # 创建并在 teardown 时删除(恢复"无属性"原状)。 monkeypatch.setattr(app.state, "event_store", None, raising=False) # spy:若被调用即标记(不应发生) called: dict[str, bool] = {"hit": False} import app.services.time_sense as time_sense_mod real_compute = time_sense_mod.compute_live_snapshot async def spy_compute(*args: Any, **kwargs: Any) -> Any: called["hit"] = True return await real_compute(*args, **kwargs) monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute) session_id = await _create_session(temporal_client) sent = await temporal_client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "你好", "modality": "text"}, ) assert sent.status_code == 200 await drain_pending_streams() # 真实行为断言:辰正未被调用 assert called["hit"] is False # 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记 captured = temporal_client._temporal_captured # type: ignore[attr-defined] sys_text = _system_text(captured["messages"]) assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记 @pytest.mark.asyncio async def test_temporal_context_swallowed_when_compute_raises( temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。""" async def boom(*_args: Any, **_kwargs: Any) -> Any: raise RuntimeError("辰正服务故障(模拟)") monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom) from app.application.chat.send_message import drain_pending_streams session_id = await _create_session(temporal_client) sent = await temporal_client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "你好", "modality": "text"}, ) # 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成 assert sent.status_code == 200 run_id = str(sent.json()["run_id"]) await drain_pending_streams() # 对话成功落库:run 进入终态 completed(而非 failed) from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.models.runtime import Run async with AsyncSessionLocal() as db: run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one() assert run.status == "completed" # temporal_context 未注入:system 消息中无辰正上下文标记 captured = temporal_client._temporal_captured # type: ignore[attr-defined] sys_text = _system_text(captured["messages"]) assert "子时" not in sys_text and "时态" not in sys_text