✨ feat: WP-03 主体——ContentBlock 全类型、Run 事件重放、分页与分支(WP-03)

- ContentBlock 全 21 类型 Pydantic 判别联合(与 openapi 契约一一对齐,extra=forbid);系统产生类 block(tool_call/tool_result/agent_status/approval_request/artifact)拒绝客户端伪造(契约 3.5)
- 迁移 4:run_event 表((run_id, sequence) 唯一、事件类型检查约束)+ conversation.current_branch_id
- Run 事件持久化:状态事件、每 block completed、message.completed 与权威状态同事务写入;sequence 单调从 0;SSE 改为重放持久化事件(不再现场构造),delta/progress 留待真实模型流(WP-05),不伪造
- GET messages:opaque cursor 分页((created_at,id) 复合键、base64 编码、limit 1-200、has_more)
- branches:列表 + 当前路径切换(采用≠归真);跨会话/不存在分支 422 fail-closed
- 测试 36 项全绿:21 类型接受、runtime block 拒绝、事件重放序号单调、分页无重叠、cursor 非法拒绝、分支切换与跨域
This commit is contained in:
chenxin committed 2026-09-10 13:17:34 +08:00
1 parent 491136c770
commit 37c53862c4
6 files changed
+627 -23

No files matched your search

+131 -1
View File
@@ -72,7 +72,7 @@ class TestMessagePipeline:
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "image", "file_id": "x", "alt": "y"}]},
json={"content": [{"type": "hologram", "file_id": "x"}]},
headers={**headers, "Idempotency-Key": "idem-bad"},
)
assert resp.status_code == 422
@@ -196,3 +196,133 @@ class TestLingtaiNormalization:
)
# fail closed:引擎不可用时显式失败,不静默降级直存
assert resp.status_code == 500
@pytest.mark.usefixtures("client")
class TestContentBlocks:
async def test_media_blocks_accepted(self, client: AsyncClient) -> None:
headers = await login(client, "blocks@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={
"content": [
{"type": "image", "file_id": "file_1", "alt": "图"},
{"type": "math", "notation": "latex", "display": "inline", "source": "a+b"},
{"type": "table", "columns": ["a"], "rows": [["1"]]},
{"type": "citation", "source_id": "s1", "title": "t", "quoted_text": "q"},
{"type": "error", "message": "块错误"},
]
},
headers={**headers, "Idempotency-Key": "idem-blocks"},
)
assert resp.status_code == 201, resp.text
types = [b["type"] for b in resp.json()["message"]["content"]]
assert types == ["image", "math", "table", "citation", "error"]
async def test_runtime_blocks_rejected_from_client(self, client: AsyncClient) -> None:
"""契约 3.5:tool_call/agent_status 等系统 block 不允许客户端伪造。"""
headers = await login(client, "rtb@example.com")
cid = await _create_conversation(client, headers)
for block in [
{"type": "tool_call", "tool_name": "t", "call_id": "c1"},
{"type": "agent_status", "stage": "s"},
{"type": "approval_request", "approval_id": "a1", "action": "do"},
{"type": "artifact", "artifact_id": "ar1", "artifact_type": "file"},
]:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [block]},
headers={**headers, "Idempotency-Key": f"idem-rtb-{block['type']}"},
)
assert resp.status_code == 422, (block["type"], resp.text)
@pytest.mark.usefixtures("client")
class TestSseReplay:
async def test_events_replayed_in_order_with_sequence(self, client: AsyncClient) -> None:
headers = await login(client, "sseq@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-seq")
run_id = resp.json()["run"]["id"]
sse = await client.get(f"/v1/runs/{run_id}/events")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
sequences = [e["sequence"] for e in events]
assert sequences == sorted(sequences) == list(range(len(events)))
assert events[0]["type"] == "run.status.changed"
assert events[-1]["type"] == "message.completed"
# 用户消息两个 block:text 与(无 markdown 时)content.block.completed 数对应
assert sum(1 for e in events if e["type"] == "content.block.completed") >= 1
@pytest.mark.usefixtures("client")
class TestPaginationAndBranches:
async def test_message_cursor_pagination(self, client: AsyncClient) -> None:
headers = await login(client, "page@example.com")
cid = await _create_conversation(client, headers)
for i in range(5):
await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": f"m{i}"}], "start_run": False},
headers={**headers, "Idempotency-Key": f"idem-p{i}"},
)
page1 = await client.get(f"/v1/conversations/{cid}/messages?limit=2", headers=headers)
assert page1.status_code == 200
body1 = page1.json()
assert len(body1["data"]) == 2 and body1["has_more"] is True
assert body1["next_cursor"]
page2 = await client.get(
f"/v1/conversations/{cid}/messages?limit=2&cursor={body1['next_cursor']}",
headers=headers,
)
body2 = page2.json()
ids1 = {m["id"] for m in body1["data"]}
ids2 = {m["id"] for m in body2["data"]}
assert not (ids1 & ids2)
last = await client.get(
f"/v1/conversations/{cid}/messages?limit=100&cursor={body2['next_cursor']}",
headers=headers,
)
assert last.json()["has_more"] is False
async def test_invalid_cursor_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "badc@example.com")
cid = await _create_conversation(client, headers)
resp = await client.get(
f"/v1/conversations/{cid}/messages?cursor=%%%", headers=headers
)
assert resp.status_code == 422
async def test_branches_and_current_path(self, client: AsyncClient) -> None:
headers = await login(client, "branch@example.com")
cid = await _create_conversation(client, headers)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=headers)
branches = listing.json()["data"]
assert len(branches) == 1 and branches[0]["title"] == "主脉"
# 切换到不存在的分支:422 fail-closed
bad = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": "branch_00000000-0000-7000-8000-000000000000"},
headers=headers,
)
assert bad.status_code == 422
# 切换到合法分支
ok = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": branches[0]["id"]},
headers=headers,
)
assert ok.status_code == 200
assert ok.json()["current_branch_id"] == branches[0]["id"]
async def test_cross_domain_branch_rejected(self, client: AsyncClient) -> None:
alice = await login(client, "br-a@example.com")
cid = await _create_conversation(client, alice)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=alice)
bid = listing.json()["data"][0]["id"]
await login(client, "br-b@example.com")
resp = await client.post(
f"/v1/conversations/{cid}/current-branch", json={"branch_id": bid}
)
assert resp.status_code in (401, 403) # 未带新会话 Cookie:401;带错 Cookie:拒绝