Files
董宏凯andCursor f82363d81f feat(maf): Phase 5 - action coordinator, approval flow, frontend MAF roundtable pages
- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates
- Backend: approved_tool_executor with write guard, action leases, migration script
- Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel)
- Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage
- Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e
- Docs: update implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 13:07:21 +08:00

239 lines
8.2 KiB
Python

"""MAF approval-to-real-tool E2E coverage using the test outbox tool.
The tool is a real LangChain StructuredTool and is loaded through the normal
``get_available_tools`` + LangGraph ``ToolNode`` path. It is intentionally
only added to this test's AppConfig; production ``config.yaml`` does not
register it.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.tool_executor import ToolExecutor
from app.maf_roundtable.write_guard_bridge import (
MafWriteGuardInterceptor,
MafWriteGuardPolicy,
register_write_guard_service,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
from deerflow.config import get_app_config
from deerflow.config.tool_config import ToolConfig
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
TEST_TOOL = "maf_test_outbox_write"
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
result = make_maf_roundtable_store(sf)
assert result is not None
yield result
await engine.dispose()
@pytest.fixture
def svc(store):
result = make_maf_service(store, enabled=True, admin_diagnostic_access=True)
register_write_guard_service(result)
return result
def _app_config():
config = get_app_config().model_copy(deep=True)
config.tools = [
*config.tools,
ToolConfig(
name=TEST_TOOL,
group="maf-e2e",
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
),
]
config.write_guard = WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
)
return config
def _snapshot():
tool = {"name": TEST_TOOL, "side_effect": "write", "risk_level": "R2"}
return {
"tools": [tool],
"agents": [{
"agent_id": "seat-1",
"allowed_tool_names": [TEST_TOOL],
"excluded_tools": [],
"tools": [tool],
}],
}
async def _make_pending_action(svc, store):
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coordinator = ActionCoordinator(svc, user_id="u1")
actions = await coordinator.intercept_write_tool_calls(
run_id=run["id"],
session_id=session["id"],
agent_id="seat-1",
turn_index=1,
tool_calls=[{
"name": TEST_TOOL,
"id": "call-outbox-1",
"args": {"operation_id": "op-1", "value": "controlled-value"},
}],
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
assert len(actions) == 1
return session, run, coordinator, actions[0]
@pytest.mark.asyncio
async def test_real_outbox_requires_approval_and_executes_once(monkeypatch, tmp_path, svc, store):
"""Pending action has no side effect; approved ToolNode call writes once."""
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
monkeypatch.setattr(
"deerflow.config.agents_config.load_agent_config",
lambda _agent_id: SimpleNamespace(tool_groups=["maf-e2e"], model=None),
)
session, run, coordinator, action = await _make_pending_action(svc, store)
outbox_file = tmp_path / "op-1.jsonl"
assert not outbox_file.exists(), "approval interception must not execute the tool"
assert await store.actions.mark_approved(action["id"]) is True
class Adapter:
class Client:
async def execute_tool_directly(self, **kwargs):
from deerflow.tools.approved_tool_executor import execute_approved_tool
return await execute_approved_tool(
app_config=_app_config(),
checkpointer=None,
store=None,
thread_id=kwargs["thread_id"],
user_id="u1",
session_id=kwargs["principal"].session_id,
run_id=kwargs["principal"].run_id,
agent_id=kwargs["agent_id"],
turn_index=kwargs["turn_index"],
model_name=None,
excluded_tools=[],
capability_snapshot=kwargs["capability_snapshot"],
write_policy=kwargs["write_policy"],
tool_name=kwargs["tool_name"],
params=kwargs["params"],
approved_action_id=kwargs["approved_action_id"],
action_record=kwargs["action_record"],
)
def __init__(self):
self._client = self.Client()
executor = ToolExecutor(
adapter=Adapter(),
action_coordinator=coordinator,
user_id="u1",
)
first = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
second = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
assert first["status"] == "succeeded"
assert second["status"] == "succeeded"
assert outbox_file.read_text(encoding="utf-8").count("\n") == 1
stored = await store.actions.get_action(action["id"])
assert stored["status"] == "succeeded"
assert stored["result"]["tool_result"]["external_object_id"] == "op-1"
@pytest.mark.asyncio
async def test_rejected_action_never_reaches_real_tool(monkeypatch, tmp_path, svc, store):
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
_session, _run, _coordinator, action = await _make_pending_action(svc, store)
assert await store.actions.mark_rejected(action["id"]) is True
assert not list(tmp_path.iterdir())
assert (await store.actions.get_action(action["id"]))["status"] == "rejected"
@pytest.mark.asyncio
async def test_missing_secret_fails_closed_before_action_creation(monkeypatch, tmp_path, svc, store):
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
middleware = WriteGuardMiddleware(
policy=MafWriteGuardPolicy(),
interceptor=MafWriteGuardInterceptor(),
)
called = 0
context = {
"maf_run_id": run["id"],
"maf_session_id": session["id"],
"maf_user_id": "u1",
"agent_id": "seat-1",
"turn_index": 1,
"write_policy": "confirm_all_writes",
"capability_snapshot": _snapshot(),
}
request = SimpleNamespace(
tool_call={
"name": TEST_TOOL,
"id": "call-missing-key",
"args": {"operation_id": "op-missing-key", "value": "must-not-run"},
},
runtime=SimpleNamespace(context=context),
)
async def handler(_request):
nonlocal called
called += 1
return {"executed": True}
result = await middleware.awrap_tool_call(request, handler)
envelope = json.loads(result.content)
assert envelope["status"] == "intercept_failed"
assert called == 0
assert not list(tmp_path.iterdir())
assert await store.actions.list_actions(run_id=run["id"]) == []