Files
deerflow-code/offline-backend-20260512/backend/tests/test_maf_roundtable_acceptance.py

1320 lines
51 KiB
Python

"""Phase 2 P1 acceptance tests for the MAF roundtable workflow execution.
These tests exercise the REAL MAF SDK workflow orchestration (WorkflowBuilder,
Executor subclasses, streaming events, checkpoint persistence) with a mocked
DeerFlow runtime client (no real LLM calls). They verify the four completion
criteria for Phase 2 P1 closure:
1. Real MAF Workflow is running.
2. Write tools never execute before approval (interception before execution).
3. After approval, the workflow resumes and executes exactly once (at-most-once).
4. Cancel and restart recovery don't leave DeerFlow orphan tasks.
Additionally covers:
- Two seats each create DeerFlow threads owned by the session user.
- MAF group_chat and structured both run a real SDK path.
- Secrets (token/cookie/password/long secret) never appear in events/errors/logs.
- source_event_id dedup at DB level.
- Audit event write failure →external write does not execute (run fails).
- ActionCoordinator: encrypted params never returned from API/events/logs.
"""
from __future__ import annotations
import asyncio
import itertools
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.action_coordinator import (
ActionCoordinator,
classify_tool,
decrypt_params,
encrypt_params,
tool_needs_approval,
)
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
from app.maf_roundtable.runtime_client import ExecutionPrincipal
from app.maf_roundtable.workflow_runner import WorkflowRunner
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
# ── fixtures ──────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_accept.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()
def _svc(store):
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=True, admin_diagnostic_access=False)
def _make_app(store):
"""Build a minimal fake app with the state the runner needs."""
app = SimpleNamespace()
app.state = SimpleNamespace()
app.state.maf_roundtable_service = _svc(store)
app.state.checkpointer = AsyncMock()
app.state.thread_store = AsyncMock()
app.state.run_manager = AsyncMock()
app.state.stream_bridge = None
return app
class _FakeRunRecord:
"""Mimics the RunRecord returned by start_run."""
def __init__(self, run_id="df-run-1"):
self.run_id = run_id
self.task = asyncio.ensure_future(asyncio.sleep(0))
def _patch_runtime_client(adapter: DeerFlowAgentAdapter, *, responses: dict[str, str] | None = None, tool_calls_map: dict[str, list] | None = None):
"""Patch the adapter's runtime client to return controlled responses.
``responses`` maps agent_id →content text.
``tool_calls_map`` maps agent_id →list of tool call dicts the AI "generated".
"""
responses = responses or {}
tool_calls_map = tool_calls_map or {}
call_log: list[dict] = []
write_tool_counter: dict[str, int] = {"count": 0}
async def fake_create_thread(principal: ExecutionPrincipal) -> str:
return f"thread-{principal.session_id[:8]}-{len(call_log)}"
async def fake_start_agent_run(principal, *, thread_id, agent_id, message, excluded_tools=None, approved_tool=None, turn_index=0, capability_snapshot=None, write_policy="confirm_high_risk", **kwargs):
call_log.append({
"agent_id": agent_id,
"thread_id": thread_id,
"message": message[:200],
"excluded_tools": list(excluded_tools or []),
"approved_tool": approved_tool,
"turn_index": turn_index,
"capability_snapshot": capability_snapshot,
"write_policy": write_policy,
})
# Phase-2 simulation: when this is a normal seat turn (no approved_tool),
# the WriteGuard middleware would intercept write tools inside the real
# DeerFlow run and create pending actions. Since the fake bypasses the
# middleware, we replay that here so the action/approval get created
# exactly as the real middleware would.
if not approved_tool and write_policy != "auto":
from app.maf_roundtable.action_coordinator import ActionCoordinator
tcs = tool_calls_map.get(agent_id, [])
coord = ActionCoordinator(adapter._service, user_id=principal.user_id)
try:
await coord.intercept_write_tool_calls(
run_id=principal.run_id,
session_id=principal.session_id,
agent_id=agent_id,
turn_index=turn_index,
tool_calls=tcs,
capability_snapshot=capability_snapshot,
write_policy=write_policy,
)
except Exception:
pass # best-effort replay for the test harness
return _FakeRunRecord(run_id=f"df-{agent_id}")
async def fake_read_messages(principal, thread_id):
agent_id = ""
for c in call_log:
if c["thread_id"] == thread_id:
agent_id = c["agent_id"]
break
# Phase-2 semantics: the WriteGuard middleware intercepts write tools
# inside the DeerFlow run. A normal seat turn (no ``approved_tool``)
# means the write was intercepted →counter stays put. Only the
# ToolExecutor's deterministic run (via execute_tool_directly) sets
# the write counter.
tcs = tool_calls_map.get(agent_id, [])
content = responses.get(agent_id, f"Response from {agent_id}")
messages = [{"type": "ai", "content": content, "usage_metadata": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}}]
if tcs:
messages[0]["tool_calls"] = tcs
return messages
async def fake_execute_tool_directly(*, thread_id, agent_id, tool_name, params, principal, **kwargs):
"""Simulate direct tool execution —the LLM is bypassed (P0-2).
Increment the write counter for the approved tool. Extra P0-1 context
kwargs (turn_index/capability_snapshot/— are accepted and ignored."""
write_tool_counter["count"] += 1
return {"status": "succeeded", "result": {"tool": tool_name, "params": params}}
adapter._client.create_thread = fake_create_thread
adapter._client.start_agent_run = fake_start_agent_run
adapter._client.read_thread_messages = fake_read_messages
adapter._client.cancel_run = AsyncMock(return_value=True)
adapter._client.execute_tool_directly = fake_execute_tool_directly
return call_log, write_tool_counter
# ── 1. Real SDK structured workflow, 2 seats ─────────────────────────────
@pytest.mark.asyncio
async def test_real_sdk_structured_workflow_two_seats(store):
"""Build and run a real MAF SDK structured workflow with 2 seat executors."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "分析市场趋势",
"mode": "structured",
"participants": [
{"agent_id": "analyst-a", "role": "seat"},
{"agent_id": "analyst-b", "role": "seat"},
],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(runner.adapter, responses={
"analyst-a": "市场呈上升趋势",
"analyst-b": "需注意风险因素",
})
await runner.execute(s, run)
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "completed"
assert final_run["finished_at"] is not None
assert final_run["started_at"] is not None
final_session = await svc.get_session(session_id=s["id"], user_id="u1")
assert final_session["status"] == "completed"
assert final_session["final_result"] is not None
# ── 2. Two seats create threads owned by session user ──────────────────
@pytest.mark.asyncio
async def test_two_seats_create_threads_owned_by_session_user(store):
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"participants": [
{"agent_id": "seat-1", "role": "seat"},
{"agent_id": "seat-2", "role": "seat"},
],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
created_threads: list[dict] = []
orig_create = runner.adapter._client.create_thread
async def tracking_create(principal: ExecutionPrincipal):
tid = await orig_create(principal)
created_threads.append({"thread_id": tid, "user_id": principal.user_id, "session_id": principal.session_id})
return tid
runner.adapter._client.create_thread = tracking_create
_patch_runtime_client(runner.adapter)
runner.adapter._client.create_thread = tracking_create
await runner.execute(s, run)
assert len(created_threads) == 2
assert all(t["user_id"] == "u1" for t in created_threads)
assert all(t["session_id"] == s["id"] for t in created_threads)
assert created_threads[0]["thread_id"] != created_threads[1]["thread_id"]
# ── 3. Two seats execute in order, producing events ─────────────────────
@pytest.mark.asyncio
async def test_two_seats_execute_in_order_producing_events(store):
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"participants": [
{"agent_id": "seat-x", "role": "seat"},
{"agent_id": "seat-y", "role": "seat"},
],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(runner.adapter)
await runner.execute(s, run)
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
event_types = [e["event_type"] for e in events]
assert event_types.count("agent.turn_started") >= 2
assert event_types.count("agent.message.completed") >= 2
assert "run.completed" in event_types
# ── 4. Seat tool call + skill + artifact events ─────────────────────────
@pytest.mark.asyncio
async def test_seat_tool_call_and_skill_artifact_events(store):
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"participants": [{"agent_id": "tool-seat", "role": "seat"}],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
from app.maf_roundtable.event_normalizer import (
make_artifact_created,
make_skill_completed,
make_skill_started,
)
await runner.adapter._emit_critical(run["id"], {
"event_type": "tool.requested",
"agent_id": "tool-seat",
"turn_index": 1,
"payload": {"tool_name": "web_search", "tool_call_id": "tc1"},
})
await runner.adapter._emit_critical(run["id"], {
"event_type": "tool.completed",
"agent_id": "tool-seat",
"turn_index": 1,
"payload": {"tool_name": "web_search", "tool_call_id": "tc1", "status": "completed"},
})
await runner.adapter._emit_critical(run["id"], make_skill_started("tool-seat", 1, skill_name="data-analysis"))
await runner.adapter._emit_critical(run["id"], make_skill_completed("tool-seat", 1, skill_name="data-analysis", result_preview="分析完成"))
await runner.adapter._emit_critical(run["id"], make_artifact_created("tool-seat", 1, artifact_type="chart", artifact_id="art-1", title="趋势图"))
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
types = [e["event_type"] for e in events]
assert "tool.requested" in types
assert "tool.completed" in types
assert "skill.started" in types
assert "skill.completed" in types
assert "artifact.created" in types
# ── 5. P1: Write tool counter —0 before approval, 1 after, still 1 after repeated resume ──
@pytest.mark.asyncio
async def test_write_tool_counter_zero_before_approval(store):
"""P1: write tools are intercepted before execution. The write tool
counter must be 0 before any approval."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "分析完成,需要创建工单"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
# Write tool was excluded →never executed →counter is 0.
assert write_counter["count"] == 0, "write tool executed before approval!"
# An action + approval should have been created.
actions = await svc.list_actions(run_id=run["id"], user_id="u1")
assert len(actions) == 1
assert actions[0]["tool_name"] == "create_ticket"
assert actions[0]["status"] == "waiting_approval"
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
assert len(approvals) == 1
assert approvals[0]["status"] == "pending"
# Run should be in waiting_approval (not completed).
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "waiting_approval"
# waiting_approval must NOT write finished_at.
assert final_run["finished_at"] is None
@pytest.mark.asyncio
async def test_write_tool_counter_one_after_approval(store):
"""P1: after approval, the workflow resumes and the write tool executes
exactly once."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
# Execute the workflow →creates action + approval →waiting_approval.
await runner.execute(s, run)
assert write_counter["count"] == 0 # Before approval: 0.
# Approve.
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
approval = approvals[0]
decided = await svc.decide_approval(approval_id=approval["id"], decision="approved", user_id="u1")
assert decided["status"] == "approved"
# Resume the workflow.
resumed = await runner.resume(run["id"])
assert resumed is not None
assert resumed["status"] == "completed"
# After approval + resume: write tool executed exactly once.
assert write_counter["count"] == 1, "write tool should execute exactly once after approval"
# Action should be succeeded.
actions = await svc.list_actions(run_id=run["id"], user_id="u1")
assert actions[0]["status"] == "succeeded"
@pytest.mark.asyncio
async def test_write_tool_still_one_after_repeated_resume(store):
"""P1: repeated resume does not double-execute the write tool."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1")
# First resume.
await runner.resume(run["id"])
assert write_counter["count"] == 1
# Second resume —should not re-execute.
await runner.resume(run["id"])
assert write_counter["count"] == 1, "repeated resume must not double-execute"
@pytest.mark.asyncio
async def test_concurrent_resume_second_sees_running_tool_runs_once(store):
"""P0-3: two concurrent resumes. The first wins the run-level CAS and blocks
inside the tool; the second sees the run already ``running`` and returns
WITHOUT writing a terminal status. The tool executes exactly once; only after
the first resume's tool is released does the run finalize (report) →completed.
"""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
# Replace the direct-tool executor with a blocking one so we can interleave
# a second resume while the first is mid-execution.
write_counter = {"count": 0}
started = asyncio.Event()
release = asyncio.Event()
async def blocking_execute_tool_directly(*, thread_id, agent_id, tool_name, params, principal, **kwargs):
write_counter["count"] += 1
started.set() # signal: first resume has claimed run+action and is executing
await release.wait() # block until the test releases it
return {"status": "succeeded", "result": {"tool": tool_name}}
runner.adapter._client.execute_tool_directly = blocking_execute_tool_directly
# Drive the workflow to waiting_approval, then approve.
await runner.execute(s, run)
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1")
# First resume (background) —wins the run CAS, then blocks in the tool.
first_task = asyncio.ensure_future(runner.resume(run["id"]))
await asyncio.wait_for(started.wait(), timeout=5)
assert write_counter["count"] == 1, "tool must have started exactly once"
# Second resume —the run is already 'running'; it must return that and must
# NOT write a terminal status or re-execute the tool.
second = await runner.resume(run["id"])
assert second is not None and second["status"] == "running", \
f"second concurrent resume must see running, got {second and second['status']}"
assert write_counter["count"] == 1, "second resume must not re-execute the tool"
# Release the first resume's tool →it finalizes (report) →completed.
release.set()
first = await asyncio.wait_for(first_task, timeout=10)
assert first is not None and first["status"] == "completed"
assert write_counter["count"] == 1, "tool executes exactly once across both resumes"
# The completed run carries a synthesized final report (finalizer re-entered).
final_run = await svc.store.runs.get_run(run["id"])
assert final_run["finished_at"] is not None
@pytest.mark.asyncio
async def test_resume_with_missing_secret_fails_without_executing(store):
"""P0-2: if the action secret is deleted before resume, the write tool must
execute **0 times**, the action is ``failed`` (not succeeded), and the parent
run is ``failed`` —never completed, never a retry of the external write."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1")
# Delete the encrypted secret out from under the approved action.
actions = await svc.list_actions(run_id=run["id"], user_id="u1")
action_id = actions[0]["id"]
await store.action_secrets.delete_by_action(action_id)
resumed = await runner.resume(run["id"])
assert write_counter["count"] == 0, "tool must NOT execute when the secret is gone"
final_actions = await svc.list_actions(run_id=run["id"], user_id="u1")
assert final_actions[0]["status"] == "failed"
assert final_actions[0]["status"] != "succeeded"
assert resumed is not None and resumed["status"] == "failed"
@pytest.mark.asyncio
async def test_resume_with_corrupt_secret_fails_without_executing(store):
"""P0-2: if the action secret cannot be decrypted (wrong key / garbage),
resume fails the action and the run with the tool executing **0 times**."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"}],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1")
# Replace the ciphertext with garbage that Fernet cannot decrypt.
actions = await svc.list_actions(run_id=run["id"], user_id="u1")
action_id = actions[0]["id"]
await store.action_secrets.delete_by_action(action_id)
await store.action_secrets.create(action_id=action_id, arguments_encrypted="corrupt-token")
resumed = await runner.resume(run["id"])
assert write_counter["count"] == 0, "tool must NOT execute when the secret is undecryptable"
final_actions = await svc.list_actions(run_id=run["id"], user_id="u1")
assert final_actions[0]["status"] == "failed"
assert resumed is not None and resumed["status"] == "failed"
# ── 6. P1: Rejected approval →write count stays 0 ──────────────────────
@pytest.mark.asyncio
async def test_rejected_approval_write_count_stays_zero(store):
"""P1: after a rejected approval, the external write count stays 0 and
the action is marked rejected."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_, write_counter = _patch_runtime_client(
runner.adapter,
responses={"writer": "需要创建工单"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="rejected", user_id="u1")
await runner.resume(run["id"])
assert write_counter["count"] == 0, "rejected approval must not execute write tool"
actions = await svc.list_actions(run_id=run["id"], user_id="u1")
assert actions[0]["status"] == "rejected"
# ── 7. P1: In-process cancel cascades to DeerFlow ───────────────────────
@pytest.mark.asyncio
async def test_in_process_cancel_cascades_to_deerflow(store):
"""P1: cancel calls DeerFlow run_manager.cancel on all active seat runs."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={"task_text": "t", "participants": [{"agent_id": "seat-1", "role": "seat"}]},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
# Create a seat run with a DeerFlow run id.
seat_run = await store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="seat-1", turn_index=1, deerflow_thread_id="thread-x"
)
await store.seat_runs.mark_running(seat_run["id"], deerflow_run_id="df-run-42")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
runner.adapter._client.cancel_run = AsyncMock(return_value=True)
result = await runner.cancel(run["id"])
assert result["all_cancelled"] is True
# DeerFlow cancel_run was called with the right run id.
runner.adapter._client.cancel_run.assert_called_once_with("df-run-42")
# Seat run should be cancelled.
seat_runs = await store.seat_runs.get_by_maf_run(run["id"])
assert seat_runs[0]["status"] == "cancelled"
# ── 8. P1: Simulated restart cancel via persisted seat runs ──────────────
@pytest.mark.asyncio
async def test_restart_cancel_via_persisted_seat_runs(store):
"""P1: after a simulated restart, the system can still cancel outstanding
DeerFlow runs via persisted seat_run records."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={"task_text": "t", "participants": [{"agent_id": "long-seat", "role": "seat"}]},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
seat_run = await store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="long-seat", turn_index=1, deerflow_thread_id="thread-x"
)
await store.seat_runs.mark_running(seat_run["id"], deerflow_run_id="df-run-99")
# "Restart" —build a fresh runner (new adapter, new client).
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
runner.adapter._client.cancel_run = AsyncMock(return_value=True)
cancelled = await runner.cancel(run["id"])
assert cancelled["all_cancelled"] is True
runner.adapter._client.cancel_run.assert_called_once_with("df-run-99")
seat_runs = await store.seat_runs.get_by_maf_run(run["id"])
assert seat_runs[0]["status"] == "cancelled"
# ── 9. P1: Two-phase cancel —cancelling then cancelled ─────────────────
@pytest.mark.asyncio
async def test_two_phase_cancel_cancelling_then_cancelled(store):
"""P1: cancel first marks seat runs as cancelling, then cancelled after
DeerFlow cancel succeeds."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
sr = await store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="s1", turn_index=1, deerflow_thread_id="t1"
)
await store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1")
infos = await store.seat_runs.cancel_all_active(run["id"])
assert len(infos) == 1
assert infos[0]["deerflow_run_id"] == "df-1"
runs = await store.seat_runs.get_by_maf_run(run["id"])
assert runs[0]["status"] == "cancelling"
await store.seat_runs.mark_cancelled(sr["id"])
runs = await store.seat_runs.get_by_maf_run(run["id"])
assert runs[0]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_failed_when_deerflow_cancel_fails(store):
"""P1: when DeerFlow cancel fails, the seat run is marked cancel_failed."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
sr = await store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="s1", turn_index=1, deerflow_thread_id="t1"
)
await store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
runner.adapter._client.cancel_run = AsyncMock(return_value=False)
result = await runner.cancel(run["id"])
assert result["all_cancelled"] is False
runs = await store.seat_runs.get_by_maf_run(run["id"])
assert runs[0]["status"] == "cancel_failed"
# ── 10. MAF group_chat runs a real SDK path ──────────────────────────────
@pytest.mark.asyncio
async def test_group_chat_runs_real_sdk_path(store):
"""P1: group_chat mode runs a real MAF SDK workflow path."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "讨论方案",
"mode": "group_chat",
"max_rounds": 2,
"participants": [
{"agent_id": "g-seat-1", "role": "seat"},
{"agent_id": "g-seat-2", "role": "seat"},
],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(runner.adapter, responses={
"g-seat-1": "我认为方案A更好",
"g-seat-2": "我同意",
})
await runner.execute(s, run)
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "completed"
# ── 11. Audit event failure prevents external write ──────────────────────
@pytest.mark.asyncio
async def test_audit_event_failure_prevents_external_write(store):
"""P1: when a critical audit event fails to persist, the adapter raises
AuditEventError and the run fails —external writes never execute."""
from app.maf_roundtable.deerflow_agent_adapter import AuditEventError
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
with patch.object(svc, "append_event", side_effect=RuntimeError("DB write failed")):
with pytest.raises(AuditEventError, match="failed to persist"):
await runner.adapter._emit_critical(run["id"], {
"event_type": "tool.requested",
"agent_id": "seat",
"turn_index": 1,
"payload": {"tool_name": "write_data"},
})
# ── 12. DeerFlow task error marks run failed ─────────────────────────────
@pytest.mark.asyncio
async def test_deerflow_task_error_marks_run_failed(store):
"""P3: when a DeerFlow seat turn raises, the seat is soft-failed
(``partial_failure``) and the structured run still completes degraded;
secrets must not appear in persisted seat / run errors."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={"task_text": "t", "participants": [{"agent_id": "boom-seat", "role": "seat"}]},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
async def exploding_start(*args, **kwargs):
raise RuntimeError("LLM provider unavailable: token=sk-secret-12345")
runner.adapter._client.create_thread = AsyncMock(return_value="thread-1")
runner.adapter._client.start_agent_run = exploding_start
await runner.execute(s, run)
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "completed"
assert final_run["finished_at"] is not None
ckpt = final_run.get("maf_checkpoint") or {}
assert ckpt.get("partial_failure") is True
outcome = (ckpt.get("seat_outcomes") or {}).get("boom-seat") or {}
assert outcome.get("status") in ("failed", "error")
persisted = " ".join(
[
str(final_run.get("error_message_safe") or ""),
str(outcome.get("error") or ""),
str(ckpt),
]
)
assert "sk-secret-12345" not in persisted
# ── 13. Secrets never in events / errors / logs ──────────────────────────
@pytest.mark.asyncio
async def test_secrets_never_in_events_or_errors(store):
"""P1: tokens, cookies, passwords, and long secrets are stripped from
event payloads, error messages, and seat-run errors."""
from app.maf_roundtable.sanitize import sanitize
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
secret_payload = {
"tool_name": "api_call",
"authorization": "Bearer sk-live-abcdef123456789",
"cookie": "session=abc123; token=xyz789",
"password": "hunter2",
"api_key": "sk-proj-1234567890abcdef",
"nested": {"secret": "deep-secret-value", "data": "safe"},
"long_text": "x" * 600 + " token=embedded-secret-999 " + "y" * 600,
}
event = await svc.append_event(
run_id=run["id"], user_id="u1", event_type="tool.completed", payload=secret_payload
)
stored = event["payload"]
assert stored["authorization"] == "***REDACTED***"
assert stored["cookie"] == "***REDACTED***"
assert stored["password"] == "***REDACTED***"
assert stored["api_key"] == "***REDACTED***"
assert stored["nested"]["secret"] == "***REDACTED***"
assert stored["nested"]["data"] == "safe"
assert "embedded-secret-999" not in stored["long_text"]
long_secret = "prefix " * 200 + "password=super-secret-value " + "suffix " * 200
sanitized = sanitize(long_secret)
assert "super-secret-value" not in sanitized
# ── 14. source_event_id dedup at DB level ────────────────────────────────
@pytest.mark.asyncio
async def test_source_event_id_dedup_at_db_level(store):
"""P1: events with the same (run_id, source_event_id) are deduped at the DB level."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
e1 = await svc.append_event(
run_id=run["id"], user_id="u1", event_type="agent.message.delta",
payload={"text": "hello"}, source_event_id="msg-abc-123",
)
e2 = await svc.append_event(
run_id=run["id"], user_id="u1", event_type="agent.message.delta",
payload={"text": "hello again"}, source_event_id="msg-abc-123",
)
assert e1["id"] == e2["id"]
assert e1["seq"] == e2["seq"]
events, latest = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 1
assert latest == 1
# ── 15. ActionCoordinator: encrypted params never returned ───────────────
@pytest.mark.asyncio
async def test_encrypted_action_params_never_returned(store):
"""P1: encrypted actual execution params are never returned from the
actions API, events, or logs."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
# Create an action with encrypted params.
action = await store.actions.create_action({
"session_id": s["id"],
"run_id": run["id"],
"agent_id": "writer",
"tool_name": "create_ticket",
"idempotency_key": f"{run['id']}:writer:1:tc-1",
"risk_level": "R3",
"status": "waiting_approval",
"arguments": {"title": "test", "authorization": "***REDACTED***"},
})
await store.action_secrets.create(
action_id=action["id"],
arguments_encrypted=encrypt_params({"title": "test", "password": "real-secret-xyz"}),
)
# The action API response must NOT contain encrypted params.
api_action = await svc.get_action(action_id=action["id"], user_id="u1")
assert "arguments_encrypted" not in str(api_action).lower() or "arguments_encrypted" not in api_action
assert "password" not in str(api_action.get("arguments", {}))
# The encrypted params can only be decrypted server-side.
decrypted = await coord.get_approved_action_params(action["id"])
assert decrypted is not None
assert decrypted["password"] == "real-secret-xyz"
# But the decrypted params must never appear in the action's API response.
api_action2 = await svc.get_action(action_id=action["id"], user_id="u1")
assert "real-secret-xyz" not in json.dumps(api_action2)
# ── 16. ActionCoordinator: tool classification ───────────────────────────
@pytest.mark.asyncio
async def test_tool_classification_metadata_driven(store):
"""P1: tools are classified from capability snapshot metadata, not name
prefixes. Tools without metadata use safe default (write/R3 →needs approval)."""
snapshot = {
"tools": [
{"name": "search", "side_effect": "read", "risk_level": "R0"},
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
{"name": "unknown_tool"}, # no metadata →safe default
],
}
# Read tool →no approval needed.
assert classify_tool("search", snapshot) == ("read", "R0")
assert not tool_needs_approval("search", "confirm_high_risk", snapshot)
# Write tool R3 →approval needed in confirm_high_risk.
assert classify_tool("create_ticket", snapshot) == ("write", "R3")
assert tool_needs_approval("create_ticket", "confirm_high_risk", snapshot)
# Unknown tool →safe default (write/R3) →approval needed.
assert classify_tool("unknown_tool", snapshot) == ("write", "R3")
assert tool_needs_approval("unknown_tool", "confirm_high_risk", snapshot)
# In auto mode →no approval needed.
assert not tool_needs_approval("create_ticket", "auto", snapshot)
# In confirm_all_writes →any write needs approval.
assert tool_needs_approval("create_ticket", "confirm_all_writes", snapshot)
# No snapshot →safe default.
assert classify_tool("any_tool", None) == ("write", "R3")
# ── 17. ActionCoordinator: encryption round-trip ─────────────────────────
def test_encryption_round_trip():
"""P1: encrypted params can be decrypted back correctly."""
params = {"title": "test", "password": "secret123", "nested": {"key": "val"}}
encrypted = encrypt_params(params)
decrypted = decrypt_params(encrypted)
assert decrypted == params
# The ciphertext must not contain the plaintext.
assert "secret123" not in encrypted
assert "test" not in encrypted
# ── 18. waiting_approval does NOT write finished_at ──────────────────────
@pytest.mark.asyncio
async def test_waiting_approval_no_finished_at(store):
"""P1: waiting_approval is NOT a terminal state —finished_at must not
be written, and both session and run should be in waiting_approval."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(
runner.adapter,
responses={"writer": "需要创建工单"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "waiting_approval"
assert final_run["finished_at"] is None
final_session = await svc.get_session(session_id=s["id"], user_id="u1")
assert final_session["status"] == "waiting_approval"
# ── 19. RoundtableState.from_dict is actually called by resume ────────────
@pytest.mark.asyncio
async def test_resume_uses_from_dict(store):
"""P1: RoundtableState.from_dict() is actually called by the resume path,
not left idle."""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "t",
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "writer", "role": "seat"}],
"capability_snapshot": {
"agents": [{"agent_id": "writer"}],
"tools": [
{"name": "create_ticket", "side_effect": "write", "risk_level": "R3"},
],
},
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
_patch_runtime_client(
runner.adapter,
responses={"writer": "工单已创建"},
tool_calls_map={"writer": [{"name": "create_ticket", "args": {"title": "test"}, "id": "tc-1"}]},
)
await runner.execute(s, run)
# Verify checkpoint was persisted.
run_after = await svc.get_run(run_id=run["id"], user_id="u1")
assert run_after["maf_checkpoint"] is not None
assert run_after["maf_checkpoint"]["run_id"] == run["id"]
# Approve and resume.
approvals = await svc.list_approvals(run_id=run["id"], user_id="u1")
await svc.decide_approval(approval_id=approvals[0]["id"], decision="approved", user_id="u1")
resumed = await runner.resume(run["id"])
assert resumed is not None
assert resumed["status"] == "completed"
# ── 20. Token usage aggregation ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_token_usage_aggregated_on_completion(store):
from app.maf_roundtable.workflow_runner import _aggregate_token_usage
turns = [
{"agent_id": "a", "turn_index": 1, "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}},
{"agent_id": "b", "turn_index": 1, "usage": {"input_tokens": 200, "output_tokens": 80, "total_tokens": 280}},
{"agent_id": "a", "turn_index": 2, "usage": None},
]
agg = _aggregate_token_usage(turns)
assert agg["total_input_tokens"] == 300
assert agg["total_output_tokens"] == 130
assert agg["total_tokens"] == 430
assert len(agg["per_seat"]) == 2
# ── 21. Capability snapshot reading ──────────────────────────────────────
@pytest.mark.asyncio
async def test_capability_snapshot_drives_seat_config(store):
from app.maf_roundtable.workflow_executors import _agent_capabilities_from_snapshot
snapshot = {
"agents": [
{
"agent_id": "seat-1",
"model_name": "gpt-4o",
"reasoning_effort": "high",
"thinking_enabled": False,
"subagent_enabled": True,
"excluded_tools": ["dangerous_tool"],
"memory_recall_disabled": True,
"memory_injection_enabled": False,
},
],
}
caps = _agent_capabilities_from_snapshot(snapshot, "seat-1")
assert caps["model_name"] == "gpt-4o"
assert caps["reasoning_effort"] == "high"
assert caps["thinking_enabled"] is False
assert caps["subagent_enabled"] is True
assert caps["excluded_tools"] == ["dangerous_tool"]
assert caps["memory_recall_disabled"] is True
assert caps["memory_injection_enabled"] is False
caps2 = _agent_capabilities_from_snapshot(snapshot, "unknown-agent")
assert caps2 == {}
caps3 = _agent_capabilities_from_snapshot(None, "seat-1")
assert caps3 == {}
# ── 22. P1-3: Real WorkflowBuilder manager stages via WorkflowRunner ─────────
@pytest.mark.asyncio
async def test_real_workflow_manager_stages_persisted(store):
"""P1-3: drive a REAL MAF WorkflowRunner (real WorkflowBuilder, real SQLite
repository, real adapter + seat-run store) through a structured run with a
manager + 2 seats. Only the runtime client is faked (no real LLM).
Asserts:
- manager has planner, cross_review, finalizer seat-run rows
- cross_review.turn_index == finalizer.turn_index
- cross_review.execution_stage != finalizer.execution_stage
"""
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={
"task_text": "分析市场趋势",
"mode": "structured",
"manager_agent_id": "manager-1",
"participants": [
{"agent_id": "manager-1", "role": "manager"},
{"agent_id": "seat-a", "role": "seat"},
{"agent_id": "seat-b", "role": "seat"},
],
},
)
run = await svc.create_run(session_id=s["id"], user_id="u1")
app = _make_app(store)
runner = WorkflowRunner(app, service=svc, user_id="u1")
manager_responses = iter([
"分工:seat-a 负责需求分析,seat-b 负责竞品分析",
json.dumps({
"summary": "审查通过,证据充分",
"evidence_ok": True,
"confirmed_candidates": [],
}),
"最终结论:市场看好,建议推进",
])
seat_responses = {
"seat-a": json.dumps({"summary": "需求旺盛", "findings": ["增长10%"]}),
"seat-b": json.dumps({"summary": "竞品A领先", "findings": ["份额30%"]}),
}
call_log: list[dict] = []
thread_counter = itertools.count()
created_threads: list[str] = []
latest_agent_by_thread: dict[str, str] = {}
async def fake_create_thread(principal):
thread_id = f"thread-{principal.session_id[:8]}-{next(thread_counter)}"
created_threads.append(thread_id)
return thread_id
async def fake_start_agent_run(principal, *, thread_id, agent_id, message, **kwargs):
latest_agent_by_thread[thread_id] = agent_id
call_log.append({"agent_id": agent_id, "thread_id": thread_id, "message": message})
return _FakeRunRecord(run_id=f"df-{agent_id}-{len(call_log)}")
async def fake_read_messages(principal, thread_id):
agent_id = latest_agent_by_thread[thread_id]
if agent_id == "manager-1":
content = next(manager_responses)
else:
content = seat_responses[agent_id]
return [{"type": "ai", "content": content, "usage_metadata": {"input_tokens": 5, "output_tokens": 5}}]
runner.adapter._client.create_thread = fake_create_thread
runner.adapter._client.start_agent_run = fake_start_agent_run
runner.adapter._client.read_thread_messages = fake_read_messages
runner.adapter._client.cancel_run = AsyncMock(return_value=True)
await runner.execute(s, run)
final_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert final_run["status"] == "completed"
seat_runs = await store.seat_runs.get_by_maf_run(run["id"])
mgr_rows = [r for r in seat_runs if r["agent_id"] == "manager-1"]
mgr_stages = {r["execution_stage"] for r in mgr_rows}
assert {"planner", "cross_review", "finalizer"}.issubset(mgr_stages), \
f"manager stages missing; got {mgr_stages}"
cr_rows = [r for r in mgr_rows if r["execution_stage"] == "cross_review"]
fin_rows = [r for r in mgr_rows if r["execution_stage"] == "finalizer"]
assert len(cr_rows) == 1 and len(fin_rows) == 1
assert cr_rows[0]["turn_index"] == fin_rows[0]["turn_index"]
assert cr_rows[0]["execution_stage"] != fin_rows[0]["execution_stage"]
assert len(created_threads) == 3
assert len(set(created_threads)) == 3
ckpt = final_run["maf_checkpoint"]
assert "最终结论:市场看好" in str(ckpt)
assert any(
r.get("agent_id") == "seat-a"
and (r.get("structured_output") or {}).get("summary") == "需求旺盛"
for r in ckpt["turn_results"]
)