代码提交

This commit is contained in:
董宏凯 committed 2026-08-05 22:20:46 +08:00
1 parent 1a080311bd
commit 428588f1c4
1 file changed
+327
@@ -262,3 +262,330 @@ async def test_manager_role_wired_through_state(svc):
planner_calls = [(a, s) for a, _, s in adapter.calls if s == "planner"] planner_calls = [(a, s) for a, _, s in adapter.calls if s == "planner"]
assert planner_calls == [(MANAGER, "planner")], \ assert planner_calls == [(MANAGER, "planner")], \
"planner stage must run as the MANAGER, not a seat" "planner stage must run as the MANAGER, not a seat"
# ── P1-3 / P1-4: Runner-level full chain tests ──────────────────────────────
@pytest_asyncio.fixture
async def v2_svc(tmp_path):
"""Service with a V2 session: manager + 3 candidate seats, dispatch_contract_enabled."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'v2runner.db'}", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
service = make_maf_service(store)
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(MafRoundtableSessionRow(
id="v2-sess", user_id="u1", title="v2", task_text="分析市场趋势",
mode="structured", status="pending", write_policy="auto",
manager_agent_id=MANAGER, max_rounds=8, created_at=now, updated_at=now,
task_payload=json.dumps({
"maf_v2": {
"dispatch_contract_enabled": True,
"candidate_set": {
"manager_agent_id": MANAGER,
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
"mode": "structured",
"max_rounds": 8,
},
},
}),
))
session.add(MafRoundtableRunRow(
id="v2-run", session_id="v2-sess", status="pending",
created_at=now, updated_at=now,
))
# Persist ALL candidates (simulating old V2 behavior).
from deerflow.persistence.maf_roundtable.model import MafRoundtableParticipantRow
for i, (agent_id, role) in enumerate([(MANAGER, "manager"), ("seat-a", "seat"), ("seat-b", "seat"), ("seat-c", "seat")]):
session.add(MafRoundtableParticipantRow(
id=f"p-{i}", session_id="v2-sess", agent_id=agent_id,
role=role, sort_order=i, created_at=now, updated_at=now,
))
await session.commit()
yield service
await engine.dispose()
def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict):
"""Create a WorkflowRunner with a mocked adapter for V2 tests."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from app.maf_roundtable.workflow_runner import WorkflowRunner
app = SimpleNamespace(state=SimpleNamespace(maf_roundtable_service=v2_svc, checkpointer=None))
runner = WorkflowRunner(app, service=v2_svc, user_id="u1")
calls: list[dict] = []
async def _run_seat(**kwargs):
calls.append(kwargs)
stage = kwargs.get("execution_stage", "")
agent_id = kwargs["agent_id"]
if stage == "manager_dispatch":
return SeatResponse(agent_id=agent_id, turn_index=0, content=json.dumps(dispatch_decision), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "direct_answer":
return SeatResponse(agent_id=agent_id, turn_index=0, content="manager direct answer", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "expert":
return SeatResponse(agent_id=agent_id, turn_index=1, content=json.dumps({"summary": f"{agent_id} result", "findings": ["f"]}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "cross_review":
return SeatResponse(agent_id=agent_id, turn_index=2, content=json.dumps({"summary": "ok", "confirmed_candidates": []}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "finalizer":
return SeatResponse(agent_id=agent_id, turn_index=3, content="final report", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
return SeatResponse(agent_id=agent_id, turn_index=0, content='{"summary":"ok"}', tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
ensure_threads_calls: list[list[str]] = []
async def _ensure_threads(session_id, run_id, participants):
agent_ids = [p["agent_id"] for p in participants]
ensure_threads_calls.append(agent_ids)
return {p["agent_id"]: f"thread-{p['agent_id']}" for p in participants}
monkeypatch.setattr(runner._adapter, "run_seat_turn", AsyncMock(side_effect=_run_seat))
monkeypatch.setattr(runner._adapter, "ensure_threads", AsyncMock(side_effect=_ensure_threads))
monkeypatch.setattr(runner._adapter, "_emit_critical", AsyncMock())
monkeypatch.setattr(runner._adapter, "_emit", AsyncMock())
return runner, calls, ensure_threads_calls
@pytest.mark.asyncio
async def test_runner_parallel_consultation_only_selected_seats(v2_svc, monkeypatch):
"""parallel_consultation: initial ensure_threads only gets manager;
after dispatch, only selected seats A/B get threads; C never appears."""
pytest.importorskip("agent_framework")
dispatch_decision = {
"intent_type": "parallel_consultation",
"selected_seat_agent_ids": ["seat-a", "seat-b"],
"assignments": [
{"agent_id": "seat-a", "task_goal": "market analysis", "delivery_requirements": ["data"], "order": 1},
{"agent_id": "seat-b", "task_goal": "competitor review", "delivery_requirements": ["sources"], "order": 2},
],
"execution_mode": "parallel",
"convergence_path": ["manager synthesis"],
"reason": "two experts needed",
}
runner, calls, ensure_threads_calls = _make_v2_runner(v2_svc, monkeypatch, dispatch_decision=dispatch_decision)
session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
run = await v2_svc.store.runs.get_run("v2-run")
await runner.execute(session, run)
# 1. First ensure_threads call: only manager.
assert len(ensure_threads_calls) >= 1
assert ensure_threads_calls[0] == [MANAGER], \
f"First ensure_threads must only get manager, got {ensure_threads_calls[0]}"
# 2. seat-c never appears in any ensure_threads call.
all_ensured = [aid for call in ensure_threads_calls for aid in call]
assert "seat-c" not in all_ensured, "seat-c must never receive a thread"
# 3. seat-a and seat-b were executed (expert stage).
expert_agents = [c["agent_id"] for c in calls if c.get("execution_stage") == "expert"]
assert "seat-a" in expert_agents
assert "seat-b" in expert_agents
assert "seat-c" not in expert_agents
# 4. Run completed.
final_run = await v2_svc.store.runs.get_run("v2-run")
assert final_run["status"] == "completed"
@pytest.mark.asyncio
async def test_runner_direct_answer_zero_seats(v2_svc, monkeypatch):
"""direct_answer: manager_only — zero seat participants, zero seat threads,
zero seat runs; final answer comes from manager."""
pytest.importorskip("agent_framework")
dispatch_decision = {
"intent_type": "direct_answer",
"selected_seat_agent_ids": [],
"assignments": [],
"execution_mode": "manager_only",
"convergence_path": ["manager answer"],
"reason": "simple question",
}
runner, calls, ensure_threads_calls = _make_v2_runner(v2_svc, monkeypatch, dispatch_decision=dispatch_decision)
session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
run = await v2_svc.store.runs.get_run("v2-run")
await runner.execute(session, run)
# 1. Only manager gets a thread.
all_ensured = [aid for call in ensure_threads_calls for aid in call]
assert all_ensured == [MANAGER], f"Only manager should get a thread, got {all_ensured}"
# 2. No seat was executed (no expert stage calls).
expert_calls = [c for c in calls if c.get("execution_stage") == "expert"]
assert expert_calls == [], "No seat should execute in direct_answer mode"
# 3. Manager produced the answer via direct_answer stage.
direct_calls = [c for c in calls if c.get("execution_stage") == "direct_answer"]
assert len(direct_calls) == 1
assert direct_calls[0]["agent_id"] == MANAGER
# 4. Run completed.
final_run = await v2_svc.store.runs.get_run("v2-run")
assert final_run["status"] == "completed"
# 5. Zero seat runs in DB.
seat_runs = await v2_svc.store.seat_runs.get_by_maf_run("v2-run")
expert_rows = [r for r in seat_runs if r["execution_stage"] == "expert"]
assert expert_rows == []
@pytest.mark.asyncio
async def test_runner_clarification_waiting_input(v2_svc, monkeypatch):
"""clarification: manager returns clarification — no seats started,
run/session end as waiting_input."""
pytest.importorskip("agent_framework")
dispatch_decision = {
"intent_type": "clarification",
"selected_seat_agent_ids": [],
"assignments": [],
"execution_mode": "manager_only",
"convergence_path": ["clarification"],
"reason": "task unclear",
"clarification_prompt": "请明确分析的目标市场和时间范围。",
}
runner, calls, ensure_threads_calls = _make_v2_runner(v2_svc, monkeypatch, dispatch_decision=dispatch_decision)
session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
run = await v2_svc.store.runs.get_run("v2-run")
await runner.execute(session, run)
# 1. Only manager gets a thread.
all_ensured = [aid for call in ensure_threads_calls for aid in call]
assert all_ensured == [MANAGER]
# 2. No seat executed.
expert_calls = [c for c in calls if c.get("execution_stage") == "expert"]
assert expert_calls == []
# 3. Run and session are waiting_input.
final_run = await v2_svc.store.runs.get_run("v2-run")
assert final_run["status"] == "waiting_input"
final_session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
assert final_session["status"] == "waiting_input"
# 4. Checkpoint preserves the clarification prompt.
checkpoint = final_run.get("maf_checkpoint") or {}
assert checkpoint.get("intent_type") == "clarification"
plan = checkpoint.get("dispatch_plan") or {}
assert plan.get("clarification_prompt") == "请明确分析的目标市场和时间范围。"
# ── P1-3: _participants_requiring_threads unit tests ────────────────────────
def test_participants_requiring_threads_legacy_returns_all():
"""Legacy MAF sessions without V2 contract keep original behavior."""
from types import SimpleNamespace
from app.maf_roundtable.workflow_runner import WorkflowRunner
app = SimpleNamespace(state=SimpleNamespace(maf_roundtable_service=None, checkpointer=None))
runner = WorkflowRunner.__new__(WorkflowRunner)
runner._app = app
runner._service = None
runner._user_id = "u1"
participants = [
{"agent_id": "manager", "role": "manager"},
{"agent_id": "seat-a", "role": "seat"},
{"agent_id": "seat-b", "role": "seat"},
]
result = runner._participants_requiring_threads(
participants,
dispatch_contract_enabled=False,
dispatch_plan=None,
intent_type=None,
)
assert result == participants
def test_participants_requiring_threads_v2_no_plan_only_manager():
"""V2 session without a validated plan: only manager gets threads."""
from types import SimpleNamespace
from app.maf_roundtable.workflow_runner import WorkflowRunner
runner = WorkflowRunner.__new__(WorkflowRunner)
runner._app = None
runner._service = None
runner._user_id = "u1"
participants = [
{"agent_id": "manager", "role": "manager"},
{"agent_id": "seat-a", "role": "seat"},
{"agent_id": "seat-b", "role": "seat"},
{"agent_id": "seat-c", "role": "seat"},
]
result = runner._participants_requiring_threads(
participants,
dispatch_contract_enabled=True,
dispatch_plan=None,
intent_type=None,
)
assert [p["agent_id"] for p in result] == ["manager"]
def test_participants_requiring_threads_v2_direct_answer_only_manager():
"""V2 with validated direct_answer plan: only manager."""
from app.maf_roundtable.workflow_runner import WorkflowRunner
runner = WorkflowRunner.__new__(WorkflowRunner)
runner._app = None
runner._service = None
runner._user_id = "u1"
participants = [
{"agent_id": "manager", "role": "manager"},
{"agent_id": "seat-a", "role": "seat"},
]
result = runner._participants_requiring_threads(
participants,
dispatch_contract_enabled=True,
dispatch_plan={"intent_type": "direct_answer", "execution_mode": "manager_only"},
intent_type="direct_answer",
)
assert [p["agent_id"] for p in result] == ["manager"]
def test_participants_requiring_threads_v2_selected_seats():
"""V2 with validated parallel plan: manager + selected seats only."""
from app.maf_roundtable.workflow_runner import WorkflowRunner
runner = WorkflowRunner.__new__(WorkflowRunner)
runner._app = None
runner._service = None
runner._user_id = "u1"
participants = [
{"agent_id": "manager", "role": "manager"},
{"agent_id": "seat-a", "role": "seat"},
{"agent_id": "seat-b", "role": "seat"},
{"agent_id": "seat-c", "role": "seat"},
]
result = runner._participants_requiring_threads(
participants,
dispatch_contract_enabled=True,
dispatch_plan={
"intent_type": "parallel_consultation",
"execution_mode": "parallel",
"seat_agent_ids": ["seat-a", "seat-b"],
},
intent_type="parallel_consultation",
)
agent_ids = [p["agent_id"] for p in result]
assert "manager" in agent_ids
assert "seat-a" in agent_ids
assert "seat-b" in agent_ids
assert "seat-c" not in agent_ids