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>
287 lines
11 KiB
Python
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",
|
|
)
|