✨ feat: WP-03 三界归真作用域与消息版本谱系(ADR-020 落地)

- 迁移 5:conversation.realm(默认真界,检查约束);message.realm 快照、canonized_at、parent_message_id 自引用与 variant_kind(origin/revision/candidate)
- 界域端点:GET conversation(realm/当前分支)与 POST realm(入境/出境显式切换,留审计——ADR-020 切换必须可见)
- 归真端点 POST /v1/messages/{id}/canonizations:scope 必须与消息界域一致(不一致 422);幂等(重复归真 200 原状态);境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);幻境归真仅标记试演记录
- 编辑版本 POST /v1/messages/{id}/revisions:新消息 variant=revision 引用原消息(markdown 修订同样经灵台引擎规范化),原消息永不修改
- 测试 44 项全绿(+8):界域快照、归真正/反/幂等、幻境试演、revision 原消息不变、跨域 401/403/404 fail-closed
This commit is contained in:
chenxin committed 2026-09-10 13:28:25 +08:00
1 parent 37c53862c4
commit 603a683c14
6 files changed
+432 -6

No files matched your search

+133
View File
@@ -326,3 +326,136 @@ class TestPaginationAndBranches:
f"/v1/conversations/{cid}/current-branch", json={"branch_id": bid}
)
assert resp.status_code in (401, 403) # 未带新会话 Cookie:401;带错 Cookie:拒绝
@pytest.mark.usefixtures("client")
class TestRealmsAndCanonization:
async def _make_message(self, client, headers, cid, key, text="hi") -> dict:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": text}], "start_run": False},
headers={**headers, "Idempotency-Key": key},
)
assert resp.status_code == 201, resp.text
return resp.json()["message"]
async def test_realm_switch_snapshots_into_new_messages(self, client: AsyncClient) -> None:
headers = await login(client, "realm1@example.com")
cid = await _create_conversation(client, headers)
detail = await client.get(f"/v1/conversations/{cid}", headers=headers)
assert detail.json()["realm"] == "reality" # 默认真界
sw = await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
assert sw.status_code == 200 and sw.json()["realm"] == "simulation"
await self._make_message(client, headers, cid, "idem-sim1")
await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
# (realm 字段在消息响应中暂未透出,以归真行为间接验证)
# 回到真界
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "reality"}, headers=headers
)
async def test_canonize_reality_message(self, client: AsyncClient) -> None:
headers = await login(client, "can1@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can1")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations",
json={"scope": "reality"},
headers=headers,
)
assert resp.status_code == 201
assert resp.json()["canonical_scope"] == "reality"
assert resp.json()["canonized_at"] is not None
async def test_canonize_idempotent_replay(self, client: AsyncClient) -> None:
headers = await login(client, "can2@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can2")
first = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
second = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
assert first.status_code == 201 and second.status_code == 200
assert second.json()["canonized_at"] == first.json()["canonized_at"]
async def test_canonize_scope_mismatch_rejected(self, client: AsyncClient) -> None:
"""真界消息不能归真到境界/幻境作用域(ADR-020 作用域一致)。"""
headers = await login(client, "can3@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can3")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "world"}, headers=headers
)
assert resp.status_code == 422
async def test_simulation_message_canonizes_as_rehearsal(self, client: AsyncClient) -> None:
"""幻境消息归真仅标记试演记录(ADR-020)。"""
headers = await login(client, "can4@example.com")
cid = await _create_conversation(client, headers)
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
msg = await self._make_message(client, headers, cid, "idem-can4")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "simulation"}, headers=headers
)
assert resp.status_code == 201
async def test_canonize_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "can5@example.com")
cid = await _create_conversation(client, alice)
msg = await self._make_message(client, alice, cid, "idem-can5")
await login(client, "can5-evil@example.com")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}
)
assert resp.status_code in (401, 403, 404) # 未携带新会话 Cookie/CSRF:拒绝
@pytest.mark.usefixtures("client")
class TestRevisions:
async def test_revision_keeps_original_intact(self, client: AsyncClient) -> None:
headers = await login(client, "rev1@example.com")
cid = await _create_conversation(client, headers)
original = None
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "原始内容"}], "start_run": False},
headers={**headers, "Idempotency-Key": "idem-rev1"},
)
original = resp.json()["message"]
rev = await client.post(
f"/v1/messages/{original['id']}/revisions",
json={"content": [{"type": "markdown", "text": "修订后\n```python\nprint(2)\n```"}]},
headers=headers,
)
assert rev.status_code == 201, rev.text
body = rev.json()
assert body["parent_message_id"] == original["id"]
assert body["variant_kind"] == "revision"
# markdown 修订经引擎规范化为两块
assert [b["type"] for b in body["content"]] == ["markdown", "code"]
# 原消息内容不变:列表中两条,原消息文本仍是"原始内容"
listing = await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
by_id = {m["id"]: m for m in listing.json()["data"]}
assert by_id[original["id"]]["content"][0]["text"] == "原始内容"
async def test_revision_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "rev2@example.com")
cid = await _create_conversation(client, alice)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "x"}], "start_run": False},
headers={**alice, "Idempotency-Key": "idem-rev2"},
)
mid = resp.json()["message"]["id"]
await login(client, "rev2-evil@example.com")
rev = await client.post(
f"/v1/messages/{mid}/revisions", json={"content": [{"type": "text", "text": "篡改"}]}
)
assert rev.status_code in (401, 403, 404)