180 lines
7.0 KiB
Python
180 lines
7.0 KiB
Python
"""对话 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
|