代码提交
This commit is contained in:
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
|
||||||
Reference in new issue
Block a user