"""M8 验收测试(rewrite-plan M8 验收门)。 ① 后台任务不构成第二意识(无身份写入路径:完成后记忆/魂籍/缘契零新增); ② 后台 Run 不占前台席位(并行于前台对话); ③ 叩心受总开关与静默约束;不回应不降低关系(无关系副作用断言)。 """ from collections.abc import AsyncGenerator from datetime import UTC, datetime, time, timedelta import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.domains import kouxin from app.main import app from app.models.jingsheng import IdentityRecord from app.models.xirang import Memory from app.domains.yuanqi.models import Covenant from app.kernel import Kernel, KernelEventLog from app.services.event_store import DbEventStore from app.shenshi_client.client import EchoShenshi @pytest.fixture async def client( monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] ) -> AsyncGenerator[AsyncClient, None]: app.state.kernel = Kernel( shenshi=EchoShenshi(), session_factory=AsyncSessionLocal, event_log=KernelEventLog(DbEventStore(AsyncSessionLocal)), timeout_seconds=10.0, ) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: yield c app.state.kernel = None async def _character(client: AsyncClient) -> dict: resp = await client.get("/api/v1/characters/active") assert resp.status_code == 200, resp.text return resp.json() async def _wait_task(client: AsyncClient, task_id: str, terminal: set[str], timeout_s: float = 15.0) -> dict: import asyncio async def poll(): while True: resp = await client.get("/api/v1/tasks", params={"parent": "all"}) task = next((t for t in resp.json() if t["id"] == task_id), None) assert task is not None if task["status"] in terminal: return task await asyncio.sleep(0.2) return await asyncio.wait_for(poll(), timeout=timeout_s) # ── ①/② 天工阁:后台 Run 无身份写入、不占前台席位 ── @pytest.mark.asyncio async def test_backstage_task_no_identity_writes(client: AsyncClient) -> None: character = await _character(client) async def _counts() -> tuple[int, int, int]: async with AsyncSessionLocal() as session: mem = len(list((await session.execute(select(Memory).where(Memory.character_id == character["id"]))).scalars())) ids = len(list((await session.execute(select(IdentityRecord).where(IdentityRecord.character_id == character["id"]))).scalars())) cov = len(list((await session.execute(select(Covenant).where(Covenant.character_id == character["id"]))).scalars())) return mem, ids, cov before = await _counts() task = await client.post( "/api/v1/tasks", json={ "character_id": character["id"], "title": "整理周报要点", "brief": "把最近一周的镜树归真内容整理为十条要点", "acceptance_criteria": "输出不超过十条要点列表", }, ) assert task.status_code == 201, task.text task_id = task.json()["id"] dispatched = await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={}) assert dispatched.status_code == 200, dispatched.text assert dispatched.json()["status"] == "running" done = await _wait_task(client, task_id, {"awaiting_acceptance", "failed"}) assert done["status"] == "awaiting_acceptance", done assert done["run_id"], "后台 Run 已关联" assert done["result_digest"], "结果证据已回填" # 无身份写入路径:记忆/魂籍/缘契零新增(第二意识断言) after = await _counts() assert after == before, f"后台任务不得写身份路径:{before} → {after}" # 按证据验收 accepted = await client.post(f"/api/v1/tasks/{task_id}/accept", json={"accepted": True}) assert accepted.status_code == 200 assert accepted.json()["status"] == "accepted" @pytest.mark.asyncio async def test_backstage_run_parallel_with_foreground(client: AsyncClient) -> None: """后台 Run 运行期间,前台对话不受席位阻塞(不占前台槽位)。""" character = await _character(client) task = await client.post( "/api/v1/tasks", json={"title": "长任务", "brief": "慢速整理", "acceptance_criteria": "完成即止"}, ) task_id = task.json()["id"] await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={}) # 后台任务运行中,前台 Run 立即可启动(回声桩即刻完成——若席位被占会超时) run = await client.post("/api/v1/runs", json={"prompt": "前台照常", "character_id": character["id"]}) assert run.status_code in (200, 201), run.text await _wait_task(client, task_id, {"awaiting_acceptance", "failed"}) @pytest.mark.asyncio async def test_task_decompose_and_cascade(client: AsyncClient) -> None: parent = await client.post( "/api/v1/tasks", json={"title": "资料三线整理", "brief": "并行整理", "acceptance_criteria": "子任务全部完成"}, ) parent_id = parent.json()["id"] subs = await client.post( f"/api/v1/tasks/{parent_id}/decompose", json={"subtasks": [ {"title": "线一", "brief": "A", "acceptance_criteria": "完成"}, {"title": "线二", "brief": "B", "acceptance_criteria": "完成"}, ]}, ) assert subs.status_code == 201 sub_ids = [s["id"] for s in subs.json()] for sid in sub_ids: await client.post(f"/api/v1/tasks/{sid}/dispatch", json={}) for sid in sub_ids: await _wait_task(client, sid, {"awaiting_acceptance", "failed"}) # 父任务级联进入待验收 parent_done = await _wait_task(client, parent_id, {"awaiting_acceptance", "failed"}) assert parent_done["status"] == "awaiting_acceptance" # ── ③ 叩心约束与无关系副作用 ── @pytest.mark.asyncio async def test_knocking_constraints(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: character = await _character(client) # 总开关关闭 → 派发被拒 monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", False) cand = await client.post( "/api/v1/knocking/candidates", json={"character_id": character["id"], "draft": "最近还好吗?"}, ) assert cand.status_code == 201 blocked = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={}) assert blocked.status_code == 429 assert "总开关" in blocked.text monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", True) # 静默窗口(强制设为全天)→ 拒 monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "00:00") monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "23:59") blocked2 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={}) assert blocked2.status_code == 429 assert "静默" in blocked2.text # 频率上限=0 → 拒 monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "03:00") monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "04:00") monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 0) blocked3 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={}) assert blocked3.status_code == 429 # 恢复常态(当前在 03:00–04:00 之外、上限 3)→ 允许派发 monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 3) ok = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={}) assert ok.status_code == 200, ok.text assert ok.json()["status"] == "dispatched" @pytest.mark.asyncio async def test_no_response_no_relationship_side_effect(client: AsyncClient) -> None: """不回应不降低关系:候选过期后缘契维度零变化。""" character = await _character(client) # 建立缘契基线 cov = await client.post( "/api/v1/covenants", json={"character_id": character["id"], "dimensions": {"trust": "high", "companionship": "warm"}}, ) assert cov.status_code == 201 await client.post(f"/api/v1/covenants/{cov.json()['id']}/seal", json={"dimensions": {"trust": "high", "companionship": "warm"}}) before = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json() # 叩心候选发出后无人回应,直至过期 cand = await client.post( "/api/v1/knocking/candidates", json={"character_id": character["id"], "trigger": "time_anchor", "draft": "想到你了"}, ) cid = cand.json()["id"] await client.post(f"/api/v1/knocking/candidates/{cid}/dispatch", json={}) expired = await kouxin.service.expire_stale(max_age_hours=0) # 立即过期 assert expired >= 1 # 无关系副作用:缘契维度与状态零变化 after = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json() assert after["dimensions"] == before["dimensions"] assert after["status"] == before["status"] assert after["version"] == before["version"], "叩心不得触发缘契版本变更"