1320 lines
51 KiB
Python
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"]
|
|
)
|