✨ 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:
1 parent
491136c770
commit
37c53862c4
6 files changed
+627
-23
No files matched your search
@@ -0,0 +1,49 @@
|
||||
"""Run 事件流与当前分支
|
||||
|
||||
Revision ID: 5cf6d328c10a
|
||||
Revises: 4cc407ec8268
|
||||
Create Date: 2026-09-10 13:13:16.305296
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5cf6d328c10a'
|
||||
down_revision: str | Sequence[str] | None = '4cc407ec8268'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('run_event',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('run_id', sa.String(), nullable=False),
|
||||
sa.Column('sequence', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("type IN ('content.block.started', 'content.block.delta', 'content.block.progress', 'content.block.completed', 'content.block.failed', 'content.block.cancelled', 'message.completed', 'run.status.changed')", name=op.f('ck_run_event_type_values')),
|
||||
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_run_event_run'), ondelete='cascade'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_run_event'))
|
||||
)
|
||||
op.create_index('uq_run_event_run_sequence', 'run_event', ['run_id', 'sequence'], unique=True)
|
||||
op.add_column('conversation', sa.Column('current_branch_id', sa.String(), nullable=True))
|
||||
op.create_foreign_key(op.f('fk_conversation_branch'), 'conversation', 'branch', ['current_branch_id'], ['id'], ondelete='set null', use_alter=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(op.f('fk_conversation_branch'), 'conversation', type_='foreignkey')
|
||||
op.drop_column('conversation', 'current_branch_id')
|
||||
op.drop_index('uq_run_event_run_sequence', table_name='run_event')
|
||||
op.drop_table('run_event')
|
||||
# ### end Alembic commands ###
|
||||
@@ -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 ""}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""对话域 schema:ContentBlock 判别联合(垂直切片子集)。
|
||||
"""对话域 schema:统一 ContentBlock 判别联合(21 类型)。
|
||||
|
||||
完整 21 类型随 WP-03 落地;本切片登记 text/markdown 并拒绝未知类型,
|
||||
契约真源为 openapi/openapi.yaml。
|
||||
与 openapi/openapi.yaml 的 components.schemas 一一对齐;此为服务端
|
||||
Pydantic 边界(AGENTS 8.2),字段语义以契约 docs/contracts/content-blocks-and-streaming.md 为准。
|
||||
markdown block 在服务端经灵台引擎规范化(契约第 4 节)。
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
@@ -16,6 +17,9 @@ class BlockBase(BaseModel):
|
||||
status: Literal["pending", "streaming", "completed", "failed", "cancelled"] | None = None
|
||||
|
||||
|
||||
# ===== 文本与结构 =====
|
||||
|
||||
|
||||
class TextBlock(BlockBase):
|
||||
type: Literal["text"]
|
||||
text: str
|
||||
@@ -28,7 +32,203 @@ class MarkdownBlock(BlockBase):
|
||||
flavor: Literal["hunjing-gfm-v1"] = "hunjing-gfm-v1"
|
||||
|
||||
|
||||
ContentBlock = Annotated[TextBlock | MarkdownBlock, Field(discriminator="type")]
|
||||
class CodeBlock(BlockBase):
|
||||
type: Literal["code"]
|
||||
source: str
|
||||
language: str | None = None
|
||||
filename: str | None = None
|
||||
|
||||
|
||||
class QuoteBlock(BlockBase):
|
||||
type: Literal["quote"]
|
||||
content: list["ContentBlock"]
|
||||
source_ref: str | None = None
|
||||
|
||||
|
||||
class TableBlock(BlockBase):
|
||||
type: Literal["table"]
|
||||
columns: list[str]
|
||||
rows: list[list[object]]
|
||||
caption: str | None = None
|
||||
|
||||
|
||||
class CalloutBlock(BlockBase):
|
||||
type: Literal["callout"]
|
||||
kind: Literal["info", "warning", "danger", "success"]
|
||||
title: str | None = None
|
||||
content: list["ContentBlock"]
|
||||
|
||||
|
||||
# ===== 数学与图 =====
|
||||
|
||||
|
||||
class MathBlock(BlockBase):
|
||||
type: Literal["math"]
|
||||
notation: Literal["latex", "asciimath"]
|
||||
display: Literal["inline", "block"]
|
||||
source: str
|
||||
fallback_text: str | None = None
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class MermaidBlock(BlockBase):
|
||||
type: Literal["mermaid"]
|
||||
source: str
|
||||
title: str | None = None
|
||||
caption: str | None = None
|
||||
fallback_code_language: str = "mermaid"
|
||||
|
||||
|
||||
# ===== 媒体与文件 =====
|
||||
|
||||
|
||||
class ImageBlock(BlockBase):
|
||||
type: Literal["image"]
|
||||
file_id: str | None = None
|
||||
url_ref: str | None = None
|
||||
alt: str
|
||||
width: int | None = Field(default=None, ge=1)
|
||||
height: int | None = Field(default=None, ge=1)
|
||||
caption: str | None = None
|
||||
|
||||
|
||||
class VideoBlock(BlockBase):
|
||||
type: Literal["video"]
|
||||
file_id: str
|
||||
poster_file_id: str | None = None
|
||||
caption: str | None = None
|
||||
transcript_file_id: str | None = None
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class AudioBlock(BlockBase):
|
||||
type: Literal["audio"]
|
||||
file_id: str
|
||||
transcript: str | None = None
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
waveform: str | None = None
|
||||
|
||||
|
||||
class FileBlock(BlockBase):
|
||||
type: Literal["file"]
|
||||
file_id: str
|
||||
filename: str
|
||||
media_type: str
|
||||
size_bytes: int = Field(ge=0)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class GalleryItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal["image", "video"]
|
||||
file_id: str
|
||||
alt: str | None = None
|
||||
caption: str | None = None
|
||||
|
||||
|
||||
class GalleryBlock(BlockBase):
|
||||
type: Literal["gallery"]
|
||||
items: list[GalleryItem]
|
||||
layout: str | None = None
|
||||
|
||||
|
||||
class AvatarBlock(BlockBase):
|
||||
type: Literal["avatar"]
|
||||
asset_id: str
|
||||
format: Literal["live2d", "vrm", "gltf"]
|
||||
preview_file_id: str | None = None
|
||||
|
||||
|
||||
# ===== Agent 与工具 =====
|
||||
|
||||
|
||||
class ToolCallBlock(BlockBase):
|
||||
type: Literal["tool_call"]
|
||||
tool_name: str
|
||||
call_id: str
|
||||
arguments_redacted: dict[str, object] | None = None
|
||||
approval_id: str | None = None
|
||||
|
||||
|
||||
class ToolResultBlock(BlockBase):
|
||||
type: Literal["tool_result"]
|
||||
call_id: str
|
||||
summary: dict[str, object] | None = None
|
||||
file_ids: list[str] | None = None
|
||||
is_error: bool | None = None
|
||||
|
||||
|
||||
class AgentStatusBlock(BlockBase):
|
||||
type: Literal["agent_status"]
|
||||
agent_id: str | None = None
|
||||
child_run_id: str | None = None
|
||||
stage: str
|
||||
progress: dict[str, object] | None = None
|
||||
waiting_reason: str | None = None
|
||||
|
||||
|
||||
class ApprovalRequestBlock(BlockBase):
|
||||
type: Literal["approval_request"]
|
||||
approval_id: str
|
||||
action: str
|
||||
impact: str | None = None
|
||||
arguments_summary: dict[str, object] | None = None
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
class ArtifactBlock(BlockBase):
|
||||
type: Literal["artifact"]
|
||||
artifact_id: str
|
||||
artifact_type: str
|
||||
version: int | None = None
|
||||
|
||||
|
||||
class CitationBlock(BlockBase):
|
||||
type: Literal["citation"]
|
||||
source_id: str
|
||||
title: str
|
||||
quoted_text: str
|
||||
locator: dict[str, object] | None = None
|
||||
|
||||
|
||||
class ErrorBlock(BlockBase):
|
||||
type: Literal["error"]
|
||||
message: str
|
||||
retryable: bool | None = None
|
||||
fallback: str | None = None
|
||||
|
||||
|
||||
_BlockTypes = (
|
||||
TextBlock
|
||||
| MarkdownBlock
|
||||
| CodeBlock
|
||||
| QuoteBlock
|
||||
| TableBlock
|
||||
| CalloutBlock
|
||||
| MathBlock
|
||||
| MermaidBlock
|
||||
| ImageBlock
|
||||
| VideoBlock
|
||||
| AudioBlock
|
||||
| FileBlock
|
||||
| GalleryBlock
|
||||
| AvatarBlock
|
||||
| ToolCallBlock
|
||||
| ToolResultBlock
|
||||
| AgentStatusBlock
|
||||
| ApprovalRequestBlock
|
||||
| ArtifactBlock
|
||||
| CitationBlock
|
||||
| ErrorBlock
|
||||
)
|
||||
|
||||
ContentBlock = Annotated[_BlockTypes, Field(discriminator="type")]
|
||||
|
||||
# 系统产生类 block:普通 Message 请求不允许客户端伪造(契约 3.5 节)
|
||||
RUNTIME_BLOCK_TYPES = frozenset(
|
||||
{"tool_call", "tool_result", "agent_status", "approval_request", "artifact"}
|
||||
)
|
||||
|
||||
|
||||
class MessageIn(BaseModel):
|
||||
@@ -59,3 +259,17 @@ class RunOut(BaseModel):
|
||||
class MessageCreatedOut(BaseModel):
|
||||
message: MessageOut
|
||||
run: RunOut | None
|
||||
|
||||
|
||||
class BranchOut(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
title: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
current_branch_id: str | None = None
|
||||
created_at: str
|
||||
@@ -13,7 +13,7 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.conversations.schemas import MessageIn
|
||||
from app.conversations.schemas import RUNTIME_BLOCK_TYPES, MessageIn
|
||||
from app.core import lingtai
|
||||
from app.core.ids import new_id
|
||||
from app.models import (
|
||||
@@ -24,6 +24,7 @@ from app.models import (
|
||||
Message,
|
||||
OutboxEvent,
|
||||
Run,
|
||||
RunEvent,
|
||||
)
|
||||
|
||||
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
|
||||
@@ -123,6 +124,13 @@ async def replay_or_execute_message(
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
branch = await _get_default_branch(db, conversation.id)
|
||||
|
||||
forged = [b.type for b in body.content if b.type in RUNTIME_BLOCK_TYPES]
|
||||
if forged:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
f"系统产生类 block 不允许客户端提交:{', '.join(sorted(set(forged)))}",
|
||||
)
|
||||
|
||||
blocks: list[dict[str, Any]] = []
|
||||
parser_version: str | None = None
|
||||
for b in body.content:
|
||||
@@ -161,6 +169,7 @@ async def replay_or_execute_message(
|
||||
resource_ids: dict[str, Any] = {"message_id": message.id}
|
||||
if run is not None:
|
||||
resource_ids["run_id"] = run.id
|
||||
_record_run_events(db, run, message, blocks)
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user",
|
||||
@@ -191,3 +200,105 @@ async def replay_or_execute_message(
|
||||
)
|
||||
await db.flush()
|
||||
return message, run, False
|
||||
|
||||
|
||||
def _record_run_events(
|
||||
db: AsyncSession, run: Run, message: Message, blocks: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""空状态闭环的事件流:状态事件 + 每 block completed + message.completed。
|
||||
|
||||
sequence 在 Run 内从 0 单调;事件与权威状态同事务提交(契约第 5 节)。
|
||||
delta/progress 事件在模型流式接入(WP-05)后由真实流产生,此处不伪造。
|
||||
"""
|
||||
seq = 0
|
||||
events: list[tuple[str, dict[str, Any]]] = [
|
||||
("run.status.changed", {"run_id": run.id, "status": run.status})
|
||||
]
|
||||
for index, block in enumerate(blocks):
|
||||
events.append(
|
||||
(
|
||||
"content.block.completed",
|
||||
{"run_id": run.id, "message_id": message.id, "index": index,
|
||||
"block": {"id": block["id"], "type": block["type"], "status": "completed"}},
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
(
|
||||
"message.completed",
|
||||
{"run_id": run.id, "message_ids": [message.id], "final_status": run.status},
|
||||
)
|
||||
)
|
||||
for event_type, data in events:
|
||||
db.add(RunEvent(run_id=run.id, sequence=seq, type=event_type, data=data))
|
||||
seq += 1
|
||||
|
||||
|
||||
async def list_messages(
|
||||
db: AsyncSession,
|
||||
domain_id: str,
|
||||
conversation_id: str,
|
||||
branch_id: str | None,
|
||||
limit: int,
|
||||
cursor: str | None,
|
||||
) -> tuple[list[Message], str | None, bool]:
|
||||
"""按创建时间升序的消息列表(opaque cursor 分页,契约第 4.1 节)。"""
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
stmt = select(Message).where(Message.conversation_id == conversation.id)
|
||||
if branch_id is not None:
|
||||
stmt = stmt.where(Message.branch_id == branch_id)
|
||||
after_created, after_id = decode_cursor(cursor)
|
||||
if after_created is not None:
|
||||
# (created_at, id) 复合键分页:严格大于游标位置
|
||||
stmt = stmt.where(
|
||||
(Message.created_at > after_created)
|
||||
| ((Message.created_at == after_created) & (Message.id > after_id))
|
||||
)
|
||||
stmt = stmt.order_by(Message.created_at.asc(), Message.id.asc()).limit(limit + 1)
|
||||
rows = list((await db.scalars(stmt)).all())
|
||||
has_more = len(rows) > limit
|
||||
page = rows[:limit]
|
||||
next_cursor = encode_cursor(page[-1]) if has_more and page else None
|
||||
return page, next_cursor, has_more
|
||||
|
||||
|
||||
def encode_cursor(message: Message) -> str:
|
||||
import base64
|
||||
|
||||
raw = f"{message.created_at.isoformat()}|{message.id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def decode_cursor(cursor: str | None) -> tuple[Any | None, str]:
|
||||
import base64
|
||||
|
||||
if cursor is None:
|
||||
return None, ""
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
created_str, _, mid = raw.partition("|")
|
||||
return datetime.fromisoformat(created_str), mid
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "cursor 不合法") from exc
|
||||
|
||||
|
||||
async def list_branches(
|
||||
db: AsyncSession, domain_id: str, conversation_id: str
|
||||
) -> list[Branch]:
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
result = await db.execute(
|
||||
select(Branch).where(Branch.conversation_id == conversation.id).order_by(Branch.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def set_current_branch(
|
||||
db: AsyncSession, domain_id: str, conversation_id: str, branch_id: str
|
||||
) -> Conversation:
|
||||
"""切换当前路径:分支必须属于该会话(跨域/跨会话统一 404/422 fail-closed)。"""
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
branch = await db.get(Branch, branch_id)
|
||||
if branch is None or branch.conversation_id != conversation.id:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "分支不属于该会话")
|
||||
conversation.current_branch_id = branch_id
|
||||
await db.flush()
|
||||
return conversation
|
||||
@@ -154,6 +154,9 @@ class Conversation(Base):
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False, default="未命名对话")
|
||||
current_branch_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("branch.id", ondelete="set null", use_alter=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -249,3 +252,31 @@ class IdempotencyRecord(Base):
|
||||
__table_args__ = (
|
||||
Index("uq_idempotency_owner_route_key", "owner_domain_id", "route", "key", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class RunEvent(Base):
|
||||
"""Run 事件流:持久化后可重放;sequence 在 Run 内单调(契约第 5 节)。"""
|
||||
|
||||
__tablename__ = "run_event"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("event_"))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("run.id", ondelete="cascade"), nullable=False
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(nullable=False)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"type IN ('content.block.started', 'content.block.delta', 'content.block.progress', "
|
||||
"'content.block.completed', 'content.block.failed', 'content.block.cancelled', "
|
||||
"'message.completed', 'run.status.changed')",
|
||||
name="type_values",
|
||||
),
|
||||
# Run 内 sequence 单调且唯一(重放去重的服务端保证)
|
||||
Index("uq_run_event_run_sequence", "run_id", "sequence", unique=True),
|
||||
)
|
||||
@@ -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:拒绝
|
||||
Reference in new issue
Block a user