✨ 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

+86 -17
View File
@@ -102,7 +102,7 @@ async def run_events(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> StreamingResponse:
"""Run SSE:返回当前快照与终止状态(垂直切片空状态闭环)。"""
"""Run SSE:重放持久化事件流(sequence 单调),客户端按序去重(契约第 5 节)。"""
result = await db.execute(select(Run).where(Run.id == run_id))
run = result.scalar_one_or_none()
if run is None:
@@ -111,29 +111,98 @@ async def run_events(
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
terminal = run.status in {"completed", "failed", "cancelled"}
from app.models import RunEvent
events = (
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
async def event_stream() -> AsyncIterator[str]:
def envelope(event_type: str, data: dict[str, object], sequence: int) -> str:
for event in events:
payload = json.dumps(
{"type": event_type, "data": data, "sequence": sequence}, ensure_ascii=False
)
return f"data: {payload}\n\n"
yield envelope("run.status.changed", {"run_id": run.id, "status": run.status}, 0)
if terminal:
yield envelope(
"message.completed",
{
"run_id": run.id,
"message_ids": [],
"final_status": run.status,
},
1,
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store"},
)
@router.get("/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
branch_id: str | None = None,
) -> dict[str, object]:
"""消息列表:opaque cursor 分页(默认 50,契约第 4.1 节)。"""
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
page, next_cursor, has_more = await service.list_messages(
db, domain.id, conversation_id, branch_id, limit, cursor
)
from app.conversations.schemas import MessageOut
return {
"data": [
MessageOut(
id=m.id,
conversation_id=m.conversation_id,
branch_id=m.branch_id,
role=m.role,
content=m.content,
parser_version=m.parser_version,
created_at=m.created_at.isoformat(),
).model_dump()
for m in page
],
"next_cursor": next_cursor,
"has_more": has_more,
}
@router.get("/{conversation_id}/branches")
async def list_branches(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, object]:
domain = await _domain_of(db, user)
branches = await service.list_branches(db, domain.id, conversation_id)
from app.conversations.schemas import BranchOut
return {
"data": [
BranchOut(
id=b.id, conversation_id=b.conversation_id, title=b.title,
created_at=b.created_at.isoformat(),
).model_dump()
for b in branches
]
}
@router.post("/{conversation_id}/current-branch")
async def set_current_branch(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, str]:
"""切换当前路径(主脉工作入口;采用≠归真)。"""
domain = await _domain_of(db, user)
conversation = await service.set_current_branch(
db, domain.id, conversation_id, body.get("branch_id", "")
)
return {"id": conversation.id, "current_branch_id": conversation.current_branch_id or ""}