Files
deerflow-code/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py
T
董宏凯andCursor 99642fca6c feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch
Backend:
- Conversation API at /api/maf-roundtable/conversations: create/list/detail,
  paginated messages, atomic send+run start, owner-only isolation,
  admin read-only diagnostic access
- SSE real-time event stream (GET /conversations/{id}/stream) backed by
  ConversationEventHub + conversation_events store; multi-worker deployment doc
- Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py):
  routing resolved from trusted MAF deployment config instead of manager-chosen
  agent ids; config.yaml keeps stage-3 routing deployment-owned
  (default_manager_agent_id / seat ids / business_routes / max_seats_per_run)
- Conversation persistence (conversations/messages stores) + v2 migration script
- Tests: conversations, conversation stream, migration, phase3 conversation dispatch

Frontend:
- Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with
  component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events
- Session form lib, flow-graph dialog / run-detail / sessions page updates,
  routes and nav tweaks
- Docs: R0 legacy interaction baseline mapping, updated implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 12:09:47 +08:00

287 lines
11 KiB
Python

"""Deterministic Stage 3 tests for V2 conversation dispatch."""
from __future__ import annotations
import asyncio
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.conversation_service import MafConversationService
from app.maf_roundtable.dispatch_contracts import (
InvalidDispatchPlanError,
MafDispatchAssignment,
MafDispatchPlan,
MafIntentDecision,
resolve_intent,
validate_dispatch_plan,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
class FakeMafConfig:
enabled = True
default_mode = "structured"
default_model_name = "test-model"
default_reasoning_effort = None
default_manager_agent_id = "manager-from-config"
default_seat_agent_ids = ["seat-a", "seat-b"]
business_routes = {}
max_seats_per_run = 4
default_max_rounds = 8
default_write_policy = "confirm_high_risk"
admin_diagnostic_access = True
class RecordingDispatcher:
def __init__(self) -> None:
self.calls: list[tuple[dict, dict, str]] = []
def check_sdk_available(self) -> None:
return
async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
self.calls.append((session, run, user_id))
class CompletingDispatcher(RecordingDispatcher):
def __init__(self, store) -> None:
super().__init__()
self._store = store
self.ready = asyncio.Event()
async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
await super().dispatch(session, run, user_id=user_id)
await self._store.runs.update_run(run["id"], {"status": "completed"})
await self._store.sessions.update_session(
session["id"], user_id=user_id, fields={"status": "completed"}
)
self.ready.set()
async def wait_for_run(self, run_id: str) -> None:
await self.ready.wait()
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'phase3.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
yield make_maf_roundtable_store(sf)
await engine.dispose()
@pytest.mark.asyncio
async def test_server_route_creates_participants_and_dispatches(store):
dispatcher = RecordingDispatcher()
svc = MafConversationService(
store,
maf_config=FakeMafConfig(),
dispatcher=dispatcher,
)
conv = await svc.create_conversation(
user_id="u1",
title="Trusted route",
task_context={"business_code": "6BF"},
)
# The unknown business code is rejected rather than falling back across
# business boundaries.
with pytest.raises(Exception) as exc_info:
await svc.send_message(
conversation_id=conv["id"], user_id="u1", content="请多专家并行分析这个任务"
)
assert "route" in str(exc_info.value)
assert dispatcher.calls == []
# With no business code the configured default route is selected and the
# runtime sees only server-owned participants.
conv2 = await svc.create_conversation(user_id="u1", title="Default route")
await svc.send_message(
conversation_id=conv2["id"], user_id="u1", content="请多专家并行分析这个任务"
)
assert len(dispatcher.calls) == 1
session, run, owner = dispatcher.calls[0]
assert owner == "u1"
assert run["session_id"] == session["id"]
async with store.session_factory() as db:
session_row = (
await db.execute(
select(MafRoundtableSessionRow).where(
MafRoundtableSessionRow.id == session["id"]
)
)
).scalars().one()
participants = (
await db.execute(
select(MafRoundtableParticipantRow)
.where(MafRoundtableParticipantRow.session_id == session["id"])
.order_by(MafRoundtableParticipantRow.sort_order)
)
).scalars().all()
assert session_row.task_payload_json["maf_v2"]["dispatch_plan"]["manager_agent_id"] == "manager-from-config"
assert [p.agent_id for p in participants] == ["manager-from-config", "seat-a", "seat-b"]
assert all(p.thread_id is None for p in participants)
events = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv2["id"]
)
)
).scalars().one()
assert events.active_run_id == run["id"]
@pytest.mark.asyncio
async def test_clarification_does_not_create_or_dispatch_run(store):
dispatcher = RecordingDispatcher()
svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
conv = await svc.create_conversation(user_id="u1", title="Need detail")
result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="不清楚")
assert result["message_id"]
assert dispatcher.calls == []
async with store.session_factory() as db:
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
)
)
).scalars().one()
assert conv_row.safe_status == "waiting_input"
assert conv_row.active_run_id is None
runs = (
await db.execute(select(MafRoundtableRunRow))
).scalars().all()
assert runs == []
# A clarification reply is allowed to claim the idle waiting-input
# conversation and launch exactly one run.
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="请分析这个任务")
assert len(dispatcher.calls) == 1
@pytest.mark.asyncio
async def test_dispatcher_completion_bridges_back_to_conversation(store):
dispatcher = CompletingDispatcher(store)
svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
conv = await svc.create_conversation(user_id="u1", title="Completion")
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
watcher_tasks = list(svc._watchers.values()) # noqa: SLF001 - lifecycle assertion
if watcher_tasks:
await asyncio.gather(*watcher_tasks)
public = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
assert public["safe_status"] == "completed"
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
assert [event["type"] for event in events][-1] == "run.completed"
assert all(event["run_id"] is None for event in events)
@pytest.mark.asyncio
async def test_workflow_projection_whitelists_user_visible_event_data(store):
svc = MafConversationService(store, maf_config=FakeMafConfig())
conv = await svc.create_conversation(user_id="u1", title="Projection")
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
internal = await store.conversations.get_conversation(conv["id"], user_id="u1")
run = await store.runs.list_runs(internal["internal_session_id"])
await svc.bridge_workflow_event(
run_id=run[0]["id"],
event={
"event_type": "tool.requested",
"agent_id": "seat-a",
"turn_index": 1,
"payload": {
"tool_name": "safe_tool",
"args": {"secret": "must-not-cross"},
"tool_call_id": "internal-call-id",
},
},
)
await svc.bridge_workflow_event(
run_id=run[0]["id"],
event={
"event_type": "agent.message.completed",
"agent_id": "seat-a",
"turn_index": 1,
"payload": {
"content": "answer",
"metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"},
},
},
)
events = await store.conversation_events.list_events(conv["id"], after_seq=3)
tool = next(event for event in events if event["type"] == "tool.requested")
assert tool["data"] == {"tool_name": "safe_tool", "agent_id": "seat-a", "turn_index": 1}
complete = next(event for event in events if event["type"] == "agent.message.complete")
assert "metadata" not in complete["data"]
assert "thread-secret" not in str(complete)
assert any(event["type"] == "seat.delivery.submitted" for event in events)
def test_intent_resolution_is_deterministic_and_does_not_select_agents():
assert resolve_intent("请多专家并行分析") .intent_type == "parallel_consultation"
assert resolve_intent("请发布这条内容").intent_type == "tool_write"
assert resolve_intent("不清楚").intent_type == "clarification"
assert resolve_intent("给我一个普通答案").intent_type == "direct_answer"
def test_weak_model_cannot_expand_or_duplicate_dispatch_plan():
with pytest.raises(ValueError):
MafDispatchPlan(
manager_agent_id="manager",
seat_agent_ids=["seat-a", "seat-a"],
assignments=[
MafDispatchAssignment(agent_id="seat-a", sort_order=1),
MafDispatchAssignment(agent_id="seat-a", sort_order=2),
],
intent_type="parallel_consultation",
max_rounds=8,
)
plan = MafDispatchPlan(
manager_agent_id="manager",
seat_agent_ids=["seat-a"],
assignments=[MafDispatchAssignment(agent_id="seat-a", sort_order=1)],
intent_type="single_seat",
max_rounds=8,
)
with pytest.raises(InvalidDispatchPlanError):
validate_dispatch_plan(plan, trusted_agent_ids={"manager", "seat-b"}, max_seats=4)
with pytest.raises(ValueError, match="at least one configured seat"):
MafDispatchPlan(
manager_agent_id="manager",
seat_agent_ids=[],
assignments=[],
intent_type="parallel_consultation",
max_rounds=8,
)
def test_clarification_plan_is_not_constructible():
with pytest.raises(ValueError):
MafIntentDecision(
intent_type="clarification",
confidence=0.9,
reason_code="needs_detail",
)