feat(position-roundtable): add standalone position collaboration

This commit is contained in:
fengxiangdebaozi committed 2026-07-21 17:50:24 +08:00
1 parent b93b82fcaf
commit 85e119db08
22 files changed
+3494 -201

No files matched your search

@@ -375,6 +375,26 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
### Position-collaboration foundation
The position-collaboration workspace is a separate frontend route and must not
use the original roundtable coordinator or jobs. Its shared configuration
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
migration). The original roundtable only reads its established seat fields and
therefore remains unchanged. The separate `/api/position-roundtable` router
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
and direct-agent node indexes in `position_roundtable_nodes` (migration
`20260721_01`). Message history remains in the established LangGraph thread
store; a node completion unlocks its next chain stage and a later upstream
revision marks downstream records `stale` for refresh.
For direct-agent handoff, the router resolves artifact paths only inside the
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
text outputs; binary artifacts stay as metadata-only references.
`POST /sessions/{id}/archive` turns a session into a read-only historical
record: task/intent updates, node binding, hidden-context construction, and
turn completion are rejected, while read endpoints remain available.
### 岗位 (Position) Subscription System
An admin-managed aggregation layer that decides, per user, which agents / skills
@@ -122,6 +122,8 @@ FastAPI application providing REST endpoints for frontend integration:
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration |
@@ -53,9 +53,10 @@ from app.gateway.routers import (
notifications,
open_3qfx,
open_chat,
parallel_agent_tasks,
parallel_agents,
positions,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
public_config,
public_embed,
public_leaderboard,
@@ -1443,8 +1444,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
app.include_router(position_roundtable.router)
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
@@ -255,9 +255,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -0,0 +1,563 @@
"""API for the standalone position-collaboration workspace.
Unlike the existing multi-agent roundtable, this feature has no coordinator and
no background roundtable job. The API only persists the task/intent/chain
snapshot and indexes one direct-agent node per business-chain seat. Actual chat
messages and artifacts stay in the established LangGraph thread stores.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"])
NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "error"}
_HANDOFF_TEXT_BUDGET = 12_000
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
_MAX_INLINE_ARTIFACT_CHARS = 1_800
_TEXT_ARTIFACT_SUFFIXES = {
".csv",
".json",
".log",
".md",
".markdown",
".rst",
".txt",
".xml",
".yaml",
".yml",
}
class NodeResponse(BaseModel):
id: str
session_id: str
node_key: str
stage_index: int
seat_index: int
agent_id: str
position_id: str | None = None
thread_id: str | None = None
status: str
latest_answer: str | None = None
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
revision: int = 0
upstream_revision: dict[str, Any] | None = None
created_at: str | None = None
updated_at: str | None = None
class SessionResponse(BaseModel):
id: str
external_task_id: str | None = None
task_snapshot: dict[str, Any] = Field(default_factory=dict)
chain_id: str | None = None
chain_snapshot: dict[str, Any] | None = None
intent_thread_id: str | None = None
intent_snapshot: dict[str, Any] | None = None
status: str
created_at: str | None = None
updated_at: str | None = None
class SessionDetailResponse(SessionResponse):
nodes: list[NodeResponse] = Field(default_factory=list)
class SessionCreateRequest(BaseModel):
external_task_id: str | None = Field(default=None, max_length=128)
task_snapshot: dict[str, Any] = Field(default_factory=dict)
class TaskSnapshotUpdateRequest(BaseModel):
task_snapshot: dict[str, Any] = Field(default_factory=dict)
class IntentUpdateRequest(BaseModel):
intent_thread_id: str | None = Field(default=None, max_length=128)
intent_snapshot: dict[str, Any] | None = None
class ActivateRequest(BaseModel):
chain_id: str = Field(min_length=1, max_length=64)
class NodeThreadRequest(BaseModel):
thread_id: str = Field(min_length=1, max_length=128)
class CompleteTurnRequest(BaseModel):
latest_answer: str = Field(default="", max_length=500_000)
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
upstream_revision: dict[str, Any] | None = None
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
return str(user.id) if user is not None else get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "position_roundtable_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Position roundtable store not available")
return store
def _get_chain_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Business chain store not available")
return store
async def _require_writable_session(
request: Request, session_id: str
) -> tuple[Any, str, dict[str, Any]]:
"""Load a Session that can still accept task, intent, or node updates."""
store = _get_store(request)
user_id = _current_user_id(request)
session = await store.get_session(session_id, user_id)
if session is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if session.get("status") == "archived":
raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录")
return store, user_id, session
def _project_session(row: dict[str, Any]) -> SessionResponse:
return SessionResponse(**row)
def _project_node(row: dict[str, Any]) -> NodeResponse:
return NodeResponse(**row)
async def _load_detail(request: Request, session_id: str) -> SessionDetailResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_session(session_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
nodes = await store.list_nodes(session_id, user_id)
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in nodes or []])
def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]:
seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
if not seats:
raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位")
seat_by_agent_id = {str(seat.get("agent_id")): seat for seat in seats if seat.get("agent_id")}
if len(seat_by_agent_id) != len(seats):
raise HTTPException(status_code=400, detail="业务链条包含无效或重复的智能体席位")
raw_stages = chain.get("stages")
stages: list[list[str]]
if isinstance(raw_stages, list) and raw_stages:
stages = [[str(agent_id) for agent_id in stage] for stage in raw_stages if isinstance(stage, list)]
else:
stages = [[str(seat["agent_id"])] for seat in seats]
if not stages or any(not stage for stage in stages):
raise HTTPException(status_code=400, detail="业务链条阶段配置无效")
result: list[dict[str, Any]] = []
for stage_index, stage in enumerate(stages):
for seat_index, agent_id in enumerate(stage):
seat = seat_by_agent_id.get(agent_id)
if seat is None:
raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}")
position_id = seat.get("position_id")
if not isinstance(position_id, str) or not position_id.strip():
raise HTTPException(
status_code=400,
detail=f"智能体「{seat.get('name') or agent_id}」尚未配置岗位,请先从岗位协同入口配置",
)
result.append(
{
"node_key": f"stage-{stage_index + 1}-seat-{seat_index + 1}-{agent_id}",
"stage_index": stage_index,
"seat_index": seat_index,
"agent_id": agent_id,
"position_id": position_id.strip(),
"status": "ready" if stage_index == 0 else "locked",
}
)
return result
def _truncate_text(value: Any, limit: int) -> str:
if limit <= 0:
return ""
text = value if isinstance(value, str) else str(value or "")
if len(text) <= limit:
return text
return f"{text[:limit]}\n…(已按交接上下文长度截断)"
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
if not isinstance(artifacts, list):
return []
refs: list[dict[str, Any]] = []
for artifact in artifacts[:20]:
if not isinstance(artifact, dict):
continue
refs.append(
{
"name": artifact.get("name") or artifact.get("filename") or "未命名产物",
"path": artifact.get("path") or artifact.get("artifact_url") or "",
"mime_type": artifact.get("mimeType") or artifact.get("mime_type") or None,
}
)
return refs
def _is_inline_text_artifact(artifact: dict[str, Any]) -> bool:
"""Return whether a listed output is safe and useful to pass as text."""
mime_type = artifact.get("mime_type")
if isinstance(mime_type, str) and mime_type.lower().startswith("text/"):
return True
path = artifact.get("path")
return isinstance(path, str) and Path(path).suffix.lower() in _TEXT_ARTIFACT_SUFFIXES
def _read_text_excerpt(path: Path, max_chars: int) -> tuple[str, bool] | None:
"""Read one bounded UTF-8 text artifact without loading the whole file."""
if max_chars <= 0:
return None
# Four bytes per UTF-8 code point is enough to obtain the requested
# character-sized excerpt without a potentially unbounded file read.
with path.open("rb") as file:
raw = file.read(max_chars * 4 + 1)
if b"\x00" in raw:
return None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as error:
if len(raw) == max_chars * 4 + 1 and error.start >= len(raw) - 4:
text = raw[: error.start].decode("utf-8")
else:
# Generated artifacts are expected to be UTF-8. Keep a non-UTF-8
# output as a reference only instead of corrupting agent context.
return None
truncated = len(raw) > max_chars * 4 or len(text) > max_chars
return _truncate_text(text, max_chars), truncated
async def _artifact_refs_with_content(
thread_id: str | None, artifacts: Any, text_budget: int
) -> tuple[list[dict[str, Any]], int]:
"""Attach bounded excerpts of text outputs, retaining binary files as refs.
Paths are always resolved against the upstream node's own thread. This is
deliberate: an artifact manifest is client-supplied metadata, so it must
never be allowed to select another thread's user-data directory.
"""
refs = _artifact_refs(artifacts)
if not thread_id or text_budget <= 0:
return refs, 0
consumed = 0
inline_count = 0
for ref in refs:
if inline_count >= _MAX_INLINE_ARTIFACTS_PER_NODE:
break
if not _is_inline_text_artifact(ref):
continue
virtual_path = ref.get("path")
if not isinstance(virtual_path, str):
continue
normalized_path = virtual_path.replace("\\", "/").lstrip("/")
if not normalized_path.startswith("mnt/user-data/outputs/"):
continue
available = text_budget - consumed
if available <= 0:
break
excerpt_limit = min(_MAX_INLINE_ARTIFACT_CHARS, available)
try:
actual_path = await aresolve_thread_virtual_path(thread_id, normalized_path)
if not await asyncio.to_thread(actual_path.is_file):
continue
excerpt = await asyncio.to_thread(_read_text_excerpt, actual_path, excerpt_limit)
except (HTTPException, OSError):
# The file may have been removed after the manifest was stored.
# Keep its metadata so the UI still shows the original output.
continue
if excerpt is None:
continue
content, truncated = excerpt
ref["content"] = content
ref["content_truncated"] = truncated
consumed += len(content)
inline_count += 1
return refs, consumed
async def _handoff_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]:
"""Construct bounded, deterministic hidden context for one direct-agent turn."""
store = _get_store(request)
user_id = _current_user_id(request)
session = await store.get_session(session_id, user_id)
nodes = await store.list_nodes(session_id, user_id)
if session is None or nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if session.get("status") == "archived":
raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录")
current = next((node for node in nodes if node["node_key"] == node_key), None)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
# Same-stage nodes are intentionally excluded. The business chain only
# hands off completed previous stages, in stage/seat order.
remaining = _HANDOFF_TEXT_BUDGET
upstream_candidates = [
node
for node in nodes
if node["stage_index"] < current["stage_index"]
and node["status"] in {"done", "stale"}
]
upstream_nodes: list[dict[str, Any]] = []
for index, node in enumerate(upstream_candidates):
candidates_left = len(upstream_candidates) - index
per_node_limit = min(2_800, max(0, (remaining // candidates_left) * 2 // 3))
answer = _truncate_text(node.get("latest_answer"), per_node_limit)
remaining -= len(answer)
artifact_budget = min(
_MAX_INLINE_ARTIFACT_CHARS,
max(0, remaining // candidates_left),
)
artifacts, consumed = await _artifact_refs_with_content(
node.get("thread_id"), node.get("artifact_manifest"), artifact_budget
)
remaining -= consumed
upstream_nodes.append(
{
"node_key": node["node_key"],
"agent_id": node["agent_id"],
"position_id": node.get("position_id"),
"revision": node.get("revision", 0),
"latest_answer": answer,
"artifacts": artifacts,
}
)
if remaining <= 0:
break
return {
"kind": "position_roundtable_handoff",
"task": session.get("task_snapshot") or {},
"intent": session.get("intent_snapshot") or {},
"current_node": {
"node_key": current["node_key"],
"agent_id": current["agent_id"],
"position_id": current.get("position_id"),
"stage_index": current["stage_index"],
"seat_index": current["seat_index"],
},
"upstream_nodes": upstream_nodes,
"upstream_revision": {
node["node_key"]: node.get("revision", 0) for node in upstream_nodes
},
}
@router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
rows = await _get_store(request).list_sessions(
_current_user_id(request), external_task_id=external_task_id
)
return [_project_session(row) for row in rows]
@router.post("/sessions", response_model=SessionResponse, status_code=201)
async def create_session(request: Request, body: SessionCreateRequest) -> SessionResponse:
row = await _get_store(request).create_session(
_current_user_id(request),
external_task_id=body.external_task_id,
task_snapshot=body.task_snapshot,
)
return _project_session(row)
@router.get("/sessions/{session_id}", response_model=SessionDetailResponse)
async def get_session(request: Request, session_id: str) -> SessionDetailResponse:
return await _load_detail(request, session_id)
@router.put("/sessions/{session_id}/task", response_model=SessionResponse)
async def update_task_snapshot(
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@router.put("/sessions/{session_id}/intent", response_model=SessionResponse)
async def update_intent_snapshot(
request: Request, session_id: str, body: IntentUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(
session_id,
user_id,
intent_thread_id=body.intent_thread_id,
intent_snapshot=body.intent_snapshot,
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@router.post("/sessions/{session_id}/activate", response_model=SessionDetailResponse)
async def activate_session(
request: Request, session_id: str, body: ActivateRequest
) -> SessionDetailResponse:
store, user_id, current = await _require_writable_session(request, session_id)
if not current.get("intent_snapshot"):
raise HTTPException(status_code=400, detail="请先完成并保存任务意图")
chain = await _get_chain_store(request).get_chain(
body.chain_id,
user_id,
is_admin=getattr(getattr(request.state, "user", None), "system_role", None) == "admin",
)
if chain is None:
raise HTTPException(status_code=404, detail="业务链条不存在或无权访问")
nodes = _chain_nodes(chain)
activated = await store.activate_session(
session_id,
user_id,
chain_id=body.chain_id,
chain_snapshot=chain,
nodes=nodes,
)
if activated is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
row, node_rows = activated
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
@router.post("/sessions/{session_id}/archive", response_model=SessionResponse)
async def archive_session(request: Request, session_id: str) -> SessionResponse:
row = await _get_store(request).archive_session(session_id, _current_user_id(request))
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@router.get("/sessions/{session_id}/nodes/{node_key}", response_model=NodeResponse)
async def get_node(request: Request, session_id: str, node_key: str) -> NodeResponse:
row = await _get_store(request).get_node(session_id, node_key, _current_user_id(request))
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
return _project_node(row)
@router.post("/sessions/{session_id}/nodes/{node_key}/thread", response_model=NodeResponse)
async def bind_node_thread(
request: Request, session_id: str, node_key: str, body: NodeThreadRequest
) -> NodeResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if current.get("thread_id") and current["thread_id"] != body.thread_id:
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
row = await store.update_node(
session_id,
node_key,
user_id,
thread_id=body.thread_id,
status="running" if current.get("status") == "ready" else current.get("status", "locked"),
)
return _project_node(row)
@router.post("/sessions/{session_id}/nodes/{node_key}/context")
async def build_node_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]:
"""Return the bounded hidden task/intent/upstream context for a node turn."""
return await _handoff_context(request, session_id, node_key)
@router.post("/sessions/{session_id}/nodes/{node_key}/complete-turn", response_model=NodeResponse)
async def complete_node_turn(
request: Request, session_id: str, node_key: str, body: CompleteTurnRequest
) -> NodeResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
node = await store.get_node(session_id, node_key, user_id)
if node is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if node["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
row = await store.update_node(
session_id,
node_key,
user_id,
status="done",
latest_answer=body.latest_answer,
artifact_manifest=body.artifact_manifest,
upstream_revision=body.upstream_revision,
increment_revision=True,
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
# A node has no coordinator. Its completion alone drives the frozen chain:
# all parallel seats in the current stage must finish before the next stage
# is made writable. Re-answering an already completed upstream seat keeps
# downstream records for audit but marks them stale so they must be
# refreshed against the new handoff context.
nodes = await store.list_nodes(session_id, user_id)
if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if node.get("revision", 0) > 0:
for downstream in nodes:
if (
downstream["stage_index"] > row["stage_index"]
and downstream["status"] in {"ready", "running", "done", "stale"}
):
await store.update_node(
session_id,
downstream["node_key"],
user_id,
status="stale",
)
nodes = await store.list_nodes(session_id, user_id) or []
current_stage = [item for item in nodes if item["stage_index"] == row["stage_index"]]
later_stages = sorted(
{item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]}
)
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
next_stage = later_stages[0]
for next_node in nodes:
if next_node["stage_index"] == next_stage and next_node["status"] == "locked":
await store.update_node(
session_id,
next_node["node_key"],
user_id,
status="ready",
)
return _project_node(row)
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
rows = await _get_store(request).list_results(session_id, _current_user_id(request))
if rows is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return [_project_node(row) for row in rows]
@@ -62,6 +62,9 @@ class ChainSeatSchema(BaseModel):
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
seat_mode: str | None = Field(default=None, max_length=16)
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
position_id: str | None = Field(default=None, max_length=64)
class ChainResponse(BaseModel):
@@ -0,0 +1,87 @@
"""Create standalone position-collaboration sessions and nodes.
The tables only index the task / intent / frozen business-chain workflow.
Conversation messages remain in LangGraph checkpoints and artifacts stay in the
existing thread artifact store, so this migration intentionally has no message
or file columns beyond lightweight snapshots and manifests.
Revision ID: 20260721_01
Revises: 20260701_01
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_01"
down_revision = "20260701_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def upgrade() -> None:
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_table("position_roundtable_sessions"):
op.create_table(
"position_roundtable_sessions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("external_task_id", sa.String(128), nullable=True),
sa.Column("task_snapshot", long_text, nullable=True),
sa.Column("chain_id", sa.String(64), nullable=True),
sa.Column("chain_snapshot", long_text, nullable=True),
sa.Column("intent_thread_id", sa.String(128), nullable=True),
sa.Column("intent_snapshot", long_text, nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="intent_pending"),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_position_roundtable_sessions_user_id", "position_roundtable_sessions", ["user_id"])
op.create_index(
"ix_position_roundtable_sessions_external_task_id",
"position_roundtable_sessions",
["external_task_id"],
)
op.create_index("ix_position_roundtable_sessions_chain_id", "position_roundtable_sessions", ["chain_id"])
op.create_index("ix_position_roundtable_sessions_status", "position_roundtable_sessions", ["status"])
if not _has_table("position_roundtable_nodes"):
op.create_table(
"position_roundtable_nodes",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("session_id", sa.String(64), sa.ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"), nullable=False),
sa.Column("node_key", sa.String(256), nullable=False),
sa.Column("stage_index", sa.Integer(), nullable=False),
sa.Column("seat_index", sa.Integer(), nullable=False),
sa.Column("agent_id", sa.String(128), nullable=False),
sa.Column("position_id", sa.String(64), nullable=True),
sa.Column("thread_id", sa.String(128), nullable=True, unique=True),
sa.Column("status", sa.String(32), nullable=False, server_default="locked"),
sa.Column("latest_answer", long_text, nullable=True),
sa.Column("artifact_manifest", long_text, nullable=True),
sa.Column("revision", sa.Integer(), nullable=False, server_default="0"),
sa.Column("upstream_revision", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
)
op.create_index("ix_position_roundtable_nodes_session_id", "position_roundtable_nodes", ["session_id"])
op.create_index("ix_position_roundtable_nodes_agent_id", "position_roundtable_nodes", ["agent_id"])
op.create_index("ix_position_roundtable_nodes_position_id", "position_roundtable_nodes", ["position_id"])
op.create_index("ix_position_roundtable_nodes_status", "position_roundtable_nodes", ["status"])
def downgrade() -> None:
if _has_table("position_roundtable_nodes"):
op.drop_table("position_roundtable_nodes")
if _has_table("position_roundtable_sessions"):
op.drop_table("position_roundtable_sessions")
@@ -46,6 +46,10 @@ from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
@@ -104,6 +108,8 @@ __all__ = [
"MenuOverrideRow",
"NotificationRow",
"ParallelAgentTaskRow",
"PositionRoundtableNodeRow",
"PositionRoundtableSessionRow",
"PositionRow",
"PositionTagBindingRow",
"RecommendedQuestionRow",
@@ -0,0 +1,22 @@
"""Position-collaboration session and node persistence."""
from __future__ import annotations
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
__all__ = [
"PositionRoundtableNodeRow",
"PositionRoundtableRepository",
"PositionRoundtableSessionRow",
"make_position_roundtable_store",
]
def make_position_roundtable_store(session_factory):
if session_factory is None:
return None
return PositionRoundtableRepository(session_factory)
@@ -0,0 +1,83 @@
"""Persistence models for the standalone position-collaboration workspace.
This deliberately does **not** reuse ``roundtable_drafts`` or
``roundtable_jobs``. A session captures the selected business chain and the
confirmed task intent; every chain seat then has one durable node record. The
node points at an existing LangGraph thread, which continues to own the actual
message history and generated artifacts.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Phase one uses normal account isolation. A future membership / role ACL
# can be added without changing the session or thread identifiers.
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# intent_pending -> active -> completed / archived. The router owns legal
# transitions; the DB keeps it as a concise, portable string.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
class PositionRoundtableNodeRow(Base):
__tablename__ = "position_roundtable_nodes"
__table_args__ = (
UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``.
node_key: Mapped[str] = mapped_column(String(256), nullable=False)
stage_index: Mapped[int] = mapped_column(Integer, nullable=False)
seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# The real messages remain in the standard LangGraph/checkpoint store.
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
# locked / ready / running / done / stale / error
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,279 @@
"""SQL repository for position-collaboration sessions and chain nodes."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
_UNSET = object()
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
def _loads(value: Any) -> Any:
if not isinstance(value, str) or not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _time(value: Any) -> str | Any:
return value.isoformat() if isinstance(value, datetime) else value
class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"external_task_id": row.external_task_id,
"task_snapshot": _loads(row.task_snapshot) or {},
"chain_id": row.chain_id,
"chain_snapshot": _loads(row.chain_snapshot),
"intent_thread_id": row.intent_thread_id,
"intent_snapshot": _loads(row.intent_snapshot),
"status": row.status,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
@staticmethod
def _node_dict(row: PositionRoundtableNodeRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"node_key": row.node_key,
"stage_index": row.stage_index,
"seat_index": row.seat_index,
"agent_id": row.agent_id,
"position_id": row.position_id,
"thread_id": row.thread_id,
"status": row.status,
"latest_answer": row.latest_answer,
"artifact_manifest": _loads(row.artifact_manifest) or [],
"revision": row.revision,
"upstream_revision": _loads(row.upstream_revision),
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
async def create_session(
self,
user_id: str,
*,
external_task_id: str | None,
task_snapshot: dict[str, Any] | None,
) -> dict[str, Any]:
now = datetime.now(UTC)
row = PositionRoundtableSessionRow(
id=uuid4().hex,
user_id=user_id,
external_task_id=external_task_id or None,
task_snapshot=_dumps(task_snapshot or {}),
status="intent_pending",
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._session_dict(row)
async def list_sessions(
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
) -> list[dict[str, Any]]:
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
if external_task_id:
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [self._session_dict(row) for row in rows]
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
return self._session_dict(row)
async def update_session(
self,
session_id: str,
user_id: str,
*,
task_snapshot: dict[str, Any] | object = _UNSET,
intent_thread_id: str | None | object = _UNSET,
intent_snapshot: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
if task_snapshot is not _UNSET:
row.task_snapshot = _dumps(task_snapshot)
if intent_thread_id is not _UNSET:
row.intent_thread_id = intent_thread_id or None
if intent_snapshot is not _UNSET:
row.intent_snapshot = _dumps(intent_snapshot)
if status is not _UNSET:
row.status = str(status)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._session_dict(row)
async def activate_session(
self,
session_id: str,
user_id: str,
*,
chain_id: str,
chain_snapshot: dict[str, Any],
nodes: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
"""Freeze a chain snapshot and create its nodes once.
Repeated requests are intentionally idempotent: once nodes exist the
existing snapshot and nodes are returned unchanged.
"""
now = datetime.now(UTC)
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
node_rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
if node_rows:
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
row.chain_id = chain_id
row.chain_snapshot = _dumps(chain_snapshot)
row.status = "active"
row.updated_at = now
node_rows = [
PositionRoundtableNodeRow(
id=uuid4().hex,
session_id=session_id,
node_key=str(node["node_key"]),
stage_index=int(node["stage_index"]),
seat_index=int(node["seat_index"]),
agent_id=str(node["agent_id"]),
position_id=node.get("position_id") or None,
status=str(node.get("status") or "locked"),
created_at=now,
updated_at=now,
)
for node in nodes
]
session.add_all(node_rows)
await session.commit()
await session.refresh(row)
for node in node_rows:
await session.refresh(node)
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
return None
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
return [self._node_dict(row) for row in rows]
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return self._node_dict(row) if row is not None else None
async def update_node(
self,
session_id: str,
node_key: str,
user_id: str,
*,
thread_id: str | None | object = _UNSET,
status: str | object = _UNSET,
latest_answer: str | None | object = _UNSET,
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
upstream_revision: dict[str, Any] | object = _UNSET,
increment_revision: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
if thread_id is not _UNSET:
row.thread_id = thread_id or None
if status is not _UNSET:
row.status = str(status)
if latest_answer is not _UNSET:
row.latest_answer = latest_answer
if artifact_manifest is not _UNSET:
row.artifact_manifest = _dumps(artifact_manifest)
if upstream_revision is not _UNSET:
row.upstream_revision = _dumps(upstream_revision)
if increment_revision:
row.revision += 1
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._node_dict(row)
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
return await self.update_session(session_id, user_id, status="archived")
async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
return await self.list_nodes(session_id, user_id)
@@ -0,0 +1,250 @@
"""Persistence coverage for the standalone position-collaboration workflow."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers import position_roundtable as position_roundtable_router
from deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}")
async with engine.begin() as conn:
# Register both tables with Base.metadata before creating the temporary DB.
import deerflow.persistence.position_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(
"user-a",
external_task_id="task-1",
task_snapshot={"title": "测试任务"},
)
await store.update_session(
created["id"],
"user-a",
intent_thread_id="intent-thread",
intent_snapshot={"objective": "识别意图"},
)
chain = {
"id": "chain-a",
"seats": [
{"agent_id": "agent-a", "position_id": "planning"},
{"agent_id": "agent-b", "position_id": "solution"},
],
}
nodes = [
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "locked",
},
]
activated, created_nodes = await store.activate_session(
created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes
)
assert activated["status"] == "active"
assert activated["chain_snapshot"] == chain
assert [node["status"] for node in created_nodes] == ["ready", "locked"]
# Repeated activate calls must return the first frozen snapshot/nodes unchanged.
repeated, repeated_nodes = await store.activate_session(
created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[]
)
assert repeated["chain_id"] == "chain-a"
assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
@pytest.mark.asyncio
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot={"id": "chain-a"},
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
}
],
)
assert len(nodes) == 1
updated = await store.update_node(
created["id"],
nodes[0]["node_key"],
"user-a",
thread_id="langgraph-thread-a",
status="done",
latest_answer="节点回答",
artifact_manifest=[{"name": "产物.md"}],
upstream_revision={"intent": 1},
increment_revision=True,
)
assert updated is not None
assert updated["thread_id"] == "langgraph-thread-a"
assert updated["latest_answer"] == "节点回答"
assert updated["artifact_manifest"] == [{"name": "产物.md"}]
assert updated["revision"] == 1
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
@pytest.mark.asyncio
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
artifact = tmp_path / "handoff.md"
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
calls: list[tuple[str, str]] = []
class StubStore:
async def get_session(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}}
async def list_nodes(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return [
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"thread_id": "upstream-thread",
"status": "done",
"revision": 1,
"latest_answer": "上游问答结果",
"artifact_manifest": [
{
"name": "handoff.md",
"path": "mnt/user-data/outputs/handoff.md",
"mime_type": "text/markdown",
},
{
"name": "outside.md",
"path": "mnt/user-data/uploads/outside.md",
"mime_type": "text/markdown",
},
],
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"thread_id": "current-thread",
"status": "ready",
"revision": 0,
"latest_answer": None,
"artifact_manifest": [],
},
]
async def resolve(thread_id: str, path: str):
calls.append((thread_id, path))
assert thread_id == "upstream-thread"
assert path == "mnt/user-data/outputs/handoff.md"
return artifact
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
context = await position_roundtable_router._handoff_context(
request, "session-a", "stage-2-seat-1-agent-b"
)
upstream = context["upstream_nodes"][0]
assert upstream["latest_answer"] == "上游问答结果"
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
assert "content" not in upstream["artifacts"][1]
assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
@pytest.mark.asyncio
async def test_archived_session_rejects_node_writes_and_keeps_history_readable():
class ArchivedStore:
async def get_session(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return {"id": session_id, "status": "archived"}
async def get_node(self, session_id, node_key, user_id):
return {
"node_key": node_key,
"status": "ready",
"thread_id": None,
}
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
with pytest.raises(HTTPException) as error:
await position_roundtable_router.bind_node_thread(
request,
"session-a",
"stage-1-seat-1-agent-a",
position_roundtable_router.NodeThreadRequest(thread_id="thread-a"),
)
assert error.value.status_code == 409
assert "已归档" in str(error.value.detail)
@pytest.mark.asyncio
async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
class LockedStore:
async def get_session(self, session_id, user_id):
return {"id": session_id, "status": "active"}
async def get_node(self, session_id, node_key, user_id):
return {
"node_key": node_key,
"status": "locked",
"thread_id": None,
}
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
with pytest.raises(HTTPException) as error:
await position_roundtable_router.bind_node_thread(
request,
"session-a",
"stage-2-seat-1-agent-b",
position_roundtable_router.NodeThreadRequest(thread_id="thread-b"),
)
assert error.value.status_code == 409
assert "未解锁" in str(error.value.detail)