feat(position-roundtable): add standalone position collaboration
This commit is contained in:
1 parent
b93b82fcaf
commit
85e119db08
22 files changed
+3494
-201
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user