M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影

- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject
- kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径
- 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契
- 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律)
- 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建
- 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化)
- 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
This commit is contained in:
chenxin committed 2026-08-28 09:13:39 +08:00
1 parent b419e0afde
commit c63b75152a
68 files changed
+1205 -4750

No files matched your search

-66
View File
@@ -1,66 +0,0 @@
"""测试 per-character goals API。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.main import app
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_goals_by_character(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test 返回该人物的目标。"""
from app.models.governance import Goal
goal = Goal(
id="goal_api_test",
title="API测试目标",
level="task",
character_id="char_test",
status="active",
)
db_session.add(goal)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test")
assert resp.status_code == 200
data = resp.json()
assert any(g["id"] == "goal_api_test" for g in data)
@pytest.mark.asyncio
async def test_list_goals_filters_status(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test&status=active 只返回 active。"""
from app.models.governance import Goal
db_session.add_all(
[
Goal(id="goal_a1", title="a1", level="task", character_id="char_test", status="active"),
Goal(
id="goal_c1", title="c1", level="task", character_id="char_test", status="completed"
),
]
)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test&status=active")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["id"] == "goal_a1"
@@ -1,135 +0,0 @@
"""洛书 Deliberative 规划器测试。"""
from app.application.planning import (
DeliberativePlan,
PlanStep,
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
def test_parse_plan_full() -> None:
"""完整 JSON 计划解析。"""
raw = """```json
{
"title": "调研项目X",
"assumptions": ["需要模型网关RAG能力"],
"risks": ["外部API可能超时"],
"steps": [
{"id": 1, "title": "搜索文档", "description": "搜索关于项目X的文档",
"tool": "web_search", "risk_level": "L2"},
{"id": 2, "title": "阅读结果", "description": "阅读搜索结果",
"tool": "read_file", "risk_level": "L1"},
{"id": 3, "title": "汇总", "description": "汇总分析",
"tool": null, "risk_level": "L1"}
]
}
```"""
plan = parse_plan(raw)
assert plan is not None
assert plan.title == "调研项目X"
assert len(plan.assumptions) == 1
assert len(plan.risks) == 1
assert len(plan.steps) == 3
assert plan.steps[0].tool == "web_search"
assert plan.steps[2].tool is None
assert plan.steps[0].risk_level == "L2"
def test_parse_plan_no_fence() -> None:
"""无 ``` 围栏的 JSON 也能解析。"""
raw = '{"title": "简单计划", "steps": [{"id": 1, "title": "执行", "description": ""}]}'
plan = parse_plan(raw)
assert plan is not None
assert plan.title == "简单计划"
assert len(plan.steps) == 1
def test_parse_plan_missing_title() -> None:
"""缺少标题 → None。"""
raw = '{"steps": [{"id": 1, "title": "执行", "description": ""}]}'
assert parse_plan(raw) is None
def test_parse_plan_empty_steps() -> None:
"""空步骤列表 → None。"""
raw = '{"title": "空计划", "steps": []}'
assert parse_plan(raw) is None
def test_parse_plan_invalid_json() -> None:
"""非法 JSON → None。"""
assert parse_plan("根本不是JSON") is None
def test_parse_plan_not_dict() -> None:
"""返回非对象 → None。"""
assert parse_plan("[1, 2, 3]") is None
def test_should_plan_triggers() -> None:
"""包含关键词的请求应触发规划。"""
triggers = [
"搜索一下项目X的背景",
"帮我调研一下最新技术",
"分析这个方案的优缺点",
"比较A和B的差异",
"研究一下这个问题",
"search for relevant docs",
"research the market",
]
for t in triggers:
assert should_plan(t), f"should plan: {t}"
def test_should_plan_no_triggers() -> None:
"""简单请求不应触发。"""
no_triggers = [
"你好",
"再见",
"今天天气怎么样",
"帮我写首诗",
]
for t in no_triggers:
assert not should_plan(t), f"should not plan: {t}"
def test_build_planning_prompt() -> None:
"""规划 prompt 包含必要结构。"""
prompt = build_planning_prompt("搜索资料", memory_context="用户关心项目X")
assert "NO_PLAN_NEEDED" in prompt
assert "搜索资料" in prompt
assert "用户关心项目X" in prompt
def test_format_plan_for_prompt() -> None:
"""计划格式化后包含步骤和元信息。"""
plan = DeliberativePlan(
title="测试计划",
steps=[
PlanStep(id=1, title="第一步", description="做A", tool="tool1", risk_level="L2"),
PlanStep(id=2, title="第二步", description="做B", risk_level="L1"),
],
assumptions=["假设1"],
risks=["风险1"],
)
text = format_plan_for_prompt(plan)
assert "测试计划" in text
assert "第一步" in text
assert "tool1" in text
assert "第二步" in text
assert "假设1" in text
assert "风险1" in text
def test_format_plan_empty_assumptions_and_risks() -> None:
"""无假设/风险时格式化正常。"""
plan = DeliberativePlan(
title="简单",
steps=[PlanStep(id=1, title="做", description="")],
)
text = format_plan_for_prompt(plan)
assert "简单" in text
assert "做" in text
-53
View File
@@ -1,53 +0,0 @@
"""司命台治理种子数据测试。"""
from collections.abc import AsyncGenerator
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.governance_seed import _GOALS, ensure_governance_seed
from app.db.session import AsyncSessionLocal
from app.models.governance import Goal
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
class TestGoalSeedsHaveCharacterId:
"""_GOALS 每条 seed 都包含 character_id 与 type 字段。"""
def test_all_goals_have_character_id_key(self) -> None:
for item in _GOALS:
assert "character_id" in item, f"{item['id']} 缺少 character_id"
def test_all_goals_have_type_key(self) -> None:
for item in _GOALS:
assert "type" in item, f"{item['id']} 缺少 type"
@pytest.mark.asyncio
async def test_seed_goals_persist_with_new_fields(
db_session: AsyncSession,
) -> None:
"""验证种子写入数据库后 character_id / type 字段被正确持久化。"""
# 先清理已有数据,确保从头写入种子
await db_session.execute(Goal.__table__.delete())
await db_session.commit()
await ensure_governance_seed(db_session)
result = await db_session.execute(select(Goal))
goals = result.scalars().all()
assert len(goals) > 0
for g in goals:
assert hasattr(g, "character_id"), f"{g.id} 缺少 character_id 属性"
assert hasattr(g, "type"), f"{g.id} 缺少 type 属性"
# character_id 在 seed 中全为 None,确保字段可空
assert g.character_id is None
# type 应与 seed 定义一致
assert g.type == "mission", f"{g.id} type 应为 mission,实际 {g.type}"
-21
View File
@@ -1,21 +0,0 @@
"""测试 Goal ORM 模型新增字段。"""
from app.models.governance import Goal
def test_goal_has_character_id():
"""Goal 模型新增 character_id 字段。"""
assert hasattr(Goal, "character_id")
def test_goal_has_type():
"""Goal 模型新增 type 字段,默认 session。"""
assert hasattr(Goal, "type")
def test_goal_has_progress():
"""Goal 模型新增 progress 字段,默认 0.0。"""
goal = Goal(title="test", level="task")
assert goal.type == "session"
assert goal.progress == 0.0
assert goal.character_id is None
@@ -1,35 +0,0 @@
"""太一会话目标提取纯函数测试。"""
from app.modules.goal_orchestrator.extract import (
extract_session_goal,
format_goal_directive,
)
def test_extract_returns_stripped_content() -> None:
assert extract_session_goal(" 帮我整理今天的笔记 ") == "帮我整理今天的笔记"
def test_extract_empty_returns_empty_string() -> None:
assert extract_session_goal("") == ""
assert extract_session_goal(" ") == ""
def test_extract_short_message_unchanged() -> None:
assert extract_session_goal("今天天气如何?") == "今天天气如何?"
def test_extract_truncates_over_limit() -> None:
# 目标摘要上限 120 字(run.goal 字段容量 300,留余量);超出截断
long = "目标描述" * 40 # 160 字
goal = extract_session_goal(long)
assert len(goal) == 120
assert goal == "目标描述" * 30 # 120 字 = 30 个「目标描述」
def test_format_goal_directive_with_goal() -> None:
assert format_goal_directive("整理笔记") == "当前会话目标:整理笔记"
def test_format_goal_directive_empty_returns_empty() -> None:
assert format_goal_directive("") == ""
@@ -1,62 +0,0 @@
"""太一枢目标数据访问测试(per-character 查询 + 进度更新)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.governance import Goal
from app.repositories import governance as repo
@pytest.mark.asyncio
async def test_list_goals_by_character() -> None:
"""返回指定 character_id 的目标列表。"""
async with AsyncSessionLocal() as session:
g1 = Goal(id="goal_1", title="t1", level="task", character_id="char_a")
g2 = Goal(id="goal_2", title="t2", level="task", character_id="char_b")
session.add_all([g1, g2])
await session.flush()
goals = await repo.list_goals_by_character(session, "char_a")
assert len(goals) == 1
assert goals[0].id == "goal_1"
@pytest.mark.asyncio
async def test_list_goals_by_character_filters_status() -> None:
"""按 status 过滤指定角色的目标。"""
async with AsyncSessionLocal() as session:
g1 = Goal(id="goal_3", title="t3", level="task", character_id="char_c", status="active")
g2 = Goal(
id="goal_4",
title="t4",
level="task",
character_id="char_c",
status="completed",
)
session.add_all([g1, g2])
await session.flush()
goals = await repo.list_goals_by_character(session, "char_c", status_filter="active")
assert len(goals) == 1
assert goals[0].id == "goal_3"
@pytest.mark.asyncio
async def test_update_goal_progress() -> None:
"""更新目标的 progress 字段。"""
async with AsyncSessionLocal() as session:
goal = Goal(id="goal_5", title="t5", level="task")
session.add(goal)
await session.flush()
updated = await repo.update_goal_progress(session, "goal_5", 0.75)
assert updated is not None
assert updated.progress == 0.75
@pytest.mark.asyncio
async def test_update_goal_progress_nonexistent() -> None:
"""更新不存在的目标返回 None。"""
async with AsyncSessionLocal() as session:
result = await repo.update_goal_progress(session, "goal_nonexistent", 0.5)
assert result is None
@@ -1,126 +0,0 @@
"""测试 governance service 中的 goal 事件写入与会话目标自动持久化。"""
import pytest
from app.core.errors import AppError
from app.models.governance import Goal
# ── auto_persist_session_goal 测试 ──
@pytest.mark.asyncio
async def test_auto_persist_session_goal_creates_new():
"""auto_persist_session_goal: 当没有活跃的 session 目标时,创建一个新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "帮助用户解决编程问题")
assert goal is not None
assert goal.level == "session"
assert goal.character_id == "char_c"
assert "编程" in goal.title
@pytest.mark.asyncio
async def test_auto_persist_session_goal_reuses_active():
"""auto_persist_session_goal: 已有活跃 session 目标时,不创建新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
existing = Goal(
id="goal_session_existing",
character_id="char_d",
level="session",
status="active",
title="已有目标",
priority=50,
)
session.add(existing)
await session.flush()
goal = await auto_persist_session_goal(session, "char_d", "新对话内容要足够长才能通过验证")
assert goal is not None
assert goal.id == "goal_session_existing" # 复用了已有目标
@pytest.mark.asyncio
async def test_auto_persist_session_goal_short_content():
"""auto_persist_session_goal: 内容过短时返回 None。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "你好")
assert goal is None
# ── governance service CRUD 测试(事件写入通过 emit_goal_event 静默跳过)──
@pytest.mark.asyncio
async def test_create_goal_success():
"""create_goal: 创建目标成功,返回 GoalNode。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal
payload = CreateGoalRequest(
title="测试目标",
description="这是一个测试目标",
level="session",
priority=50,
)
result = await create_goal(payload)
assert result.title == "测试目标"
assert result.level == "session"
assert result.status == "pending"
@pytest.mark.asyncio
async def test_update_goal_success():
"""update_goal: 更新目标成功。"""
from app.schemas.admin import CreateGoalRequest, UpdateGoalRequest
from app.services.governance import create_goal, update_goal
payload = CreateGoalRequest(
title="待更新目标",
description="稍后更新",
level="task",
priority=50,
)
created = await create_goal(payload)
update = UpdateGoalRequest(title="已更新目标", status="active")
updated = await update_goal(created.id, update)
assert updated.title == "已更新目标"
assert updated.status == "active"
@pytest.mark.asyncio
async def test_update_goal_not_found():
"""update_goal: 目标不存在时抛出 AppError。"""
from app.schemas.admin import UpdateGoalRequest
from app.services.governance import update_goal
update = UpdateGoalRequest(title="不存在")
with pytest.raises(AppError) as exc:
await update_goal("goal_nonexistent", update)
assert "not_found" in str(exc.value)
@pytest.mark.asyncio
async def test_delete_goal_success():
"""delete_goal: 删除目标成功。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal, delete_goal, list_goals
payload = CreateGoalRequest(
title="待删除目标",
description="稍后删除",
level="task",
priority=50,
)
created = await create_goal(payload)
await delete_goal(created.id)
all_goals = await list_goals()
assert not any(g.id == created.id for g in all_goals)
+226
View File
@@ -0,0 +1,226 @@
"""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"], "叩心不得触发缘契版本变更"