2026-08-17 19:36:42 +02:00
|
|
|
"""Phase F — Agent system tests (F-TEST).
|
|
|
|
|
|
|
|
|
|
Comprehensive tests for the Phase F agent system. All tests use mocks
|
|
|
|
|
(AsyncMock / MagicMock) — no real DB, no real LLM, no Redis required.
|
|
|
|
|
|
|
|
|
|
Covers:
|
|
|
|
|
1. ReAct loop (multi-step, max_steps, timeout, error recovery, cost, dry-run, audit)
|
|
|
|
|
2. Agent permissions (intersection, system admin, visibility, execute, optimistic lock)
|
|
|
|
|
3. Approval requests (create, approve/reject/expire, list filters)
|
|
|
|
|
4. Skill registry (registration, get_by_names, no permission grant)
|
|
|
|
|
5. Context builder (system prompt, ReAct format, sensitive fields, tool descriptions)
|
|
|
|
|
6. Data policy (sensitive field removal, provider compliance)
|
|
|
|
|
7. Transparency (AI-generated marking, AI participant detection)
|
|
|
|
|
8. Workstream integration (message, step, result)
|
|
|
|
|
9. Budget limits (run stops on budget exceeded, cost accumulation)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import uuid
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-08-25 22:02:59 +02:00
|
|
|
from app.ai.agent_loop import run_react_loop
|
2026-08-17 19:36:42 +02:00
|
|
|
from app.ai.agent_permissions import (
|
|
|
|
|
AgentPermissionContext,
|
|
|
|
|
check_agent_execute_permission,
|
|
|
|
|
check_entity_lock,
|
|
|
|
|
filter_visible_agents,
|
|
|
|
|
resolve_agent_permissions,
|
|
|
|
|
)
|
|
|
|
|
from app.ai.context_builder import ReActSystemPromptBuilder, build_agent_context
|
|
|
|
|
from app.ai.data_policy import enforce_data_policy
|
2026-08-25 22:02:59 +02:00
|
|
|
from app.ai.skill_registry import SkillDefinition, get_skill_registry
|
2026-08-17 19:36:42 +02:00
|
|
|
from app.ai.transparency import is_ai_participant, mark_as_ai_generated
|
|
|
|
|
from app.core.approval import (
|
|
|
|
|
ApprovalRequest,
|
|
|
|
|
create_approval_request,
|
|
|
|
|
expire_approval_request,
|
|
|
|
|
resolve_approval_request,
|
|
|
|
|
)
|
|
|
|
|
from app.core.error_codes import ApiError
|
|
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# No-op overrides of conftest DB fixtures — these tests use mocks only
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
|
|
|
def db_setup():
|
|
|
|
|
"""No-op override of conftest db_setup (no real DB needed)."""
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def clean_tables(db_setup):
|
|
|
|
|
"""No-op override of conftest clean_tables."""
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# Shared helpers
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class MockAgentDefinition:
|
|
|
|
|
"""Minimal stand-in for AgentDefinition used across tests."""
|
|
|
|
|
|
|
|
|
|
id: uuid.UUID = field(default_factory=uuid.uuid4)
|
|
|
|
|
name: str = "Test Agent"
|
|
|
|
|
description: str = "A test agent"
|
|
|
|
|
llm_model: str = "gpt-4o"
|
|
|
|
|
system_prompt: str = "You are a helpful assistant."
|
|
|
|
|
api_key: str | None = None
|
|
|
|
|
api_base: str | None = None
|
|
|
|
|
provider: str | None = None
|
|
|
|
|
max_tokens: int = 1000
|
|
|
|
|
max_steps: int = 20
|
|
|
|
|
tool_ids: list[str] = field(default_factory=list)
|
|
|
|
|
skill_ids: list[str] = field(default_factory=list)
|
|
|
|
|
capabilities: list[str] = field(default_factory=list)
|
|
|
|
|
budget_limit_usd: float = 1.0
|
|
|
|
|
trace_mode: str = "standard"
|
|
|
|
|
ai_use_case_metadata: dict[str, Any] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class MockTool:
|
|
|
|
|
"""Minimal stand-in for AITool."""
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
description: str = ""
|
|
|
|
|
required_permission: str | None = None
|
|
|
|
|
|
|
|
|
|
def to_openai_schema(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": self.name,
|
|
|
|
|
"description": self.description,
|
|
|
|
|
"parameters": {},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MockToolRegistry:
|
|
|
|
|
"""In-memory tool registry with get_by_names / get / get_all / list_tools."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, tools: list[MockTool] | None = None) -> None:
|
|
|
|
|
self._tools = {t.name: t for t in (tools or [])}
|
|
|
|
|
|
|
|
|
|
def get_by_names(self, names: list[str]) -> list[MockTool]:
|
|
|
|
|
return [self._tools[n] for n in names if n in self._tools]
|
|
|
|
|
|
|
|
|
|
def get(self, name: str) -> MockTool | None:
|
|
|
|
|
return self._tools.get(name)
|
|
|
|
|
|
|
|
|
|
def get_all(self) -> list[MockTool]:
|
|
|
|
|
return list(self._tools.values())
|
|
|
|
|
|
|
|
|
|
def list_tools(self) -> list[dict[str, Any]]:
|
|
|
|
|
return [
|
|
|
|
|
{"id": t.name, "name": t.name, "description": t.description, "plugin": "test"}
|
|
|
|
|
for t in self._tools.values()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_tool_call(
|
|
|
|
|
call_id: str = "call_1",
|
|
|
|
|
name: str = "search",
|
|
|
|
|
arguments: dict[str, Any] | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Build a mock LiteLLM tool-call object."""
|
|
|
|
|
args_str = json.dumps(arguments or {})
|
|
|
|
|
function_mock = MagicMock()
|
|
|
|
|
function_mock.name = name
|
|
|
|
|
function_mock.arguments = args_str
|
|
|
|
|
tc = MagicMock()
|
|
|
|
|
tc.id = call_id
|
|
|
|
|
tc.function = function_mock
|
|
|
|
|
return tc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_llm_response(
|
|
|
|
|
content: str = "",
|
|
|
|
|
tool_calls: list[Any] | None = None,
|
|
|
|
|
cost_usd: float = 0.001,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Build a mock llm_complete() return value."""
|
|
|
|
|
message = MagicMock()
|
|
|
|
|
message.content = content
|
|
|
|
|
message.tool_calls = tool_calls or None
|
|
|
|
|
|
|
|
|
|
choice = MagicMock()
|
|
|
|
|
choice.message = message
|
|
|
|
|
|
|
|
|
|
raw_response = MagicMock()
|
|
|
|
|
raw_response.choices = [choice]
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"content": content,
|
|
|
|
|
"usage": {"total_tokens": 100},
|
|
|
|
|
"cost_usd": cost_usd,
|
|
|
|
|
"model": "gpt-4o",
|
|
|
|
|
"raw_response": raw_response,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_tool_registry(tools: dict[str, AsyncMock] | None = None) -> MagicMock:
|
|
|
|
|
"""Build a mock ToolRegistry for the ReAct loop."""
|
|
|
|
|
registry = MagicMock()
|
|
|
|
|
_tools = tools or {}
|
|
|
|
|
|
|
|
|
|
def _get(name: str) -> Any:
|
|
|
|
|
if name in _tools:
|
|
|
|
|
tool = MagicMock()
|
|
|
|
|
tool.handler = _tools[name]
|
|
|
|
|
return tool
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
registry.get = _get
|
|
|
|
|
return registry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _permissions(
|
|
|
|
|
permissions: list[str] | None = None,
|
|
|
|
|
denied: list[str] | None = None,
|
|
|
|
|
is_system_admin: bool = False,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"permissions": set(permissions or []),
|
|
|
|
|
"denied": set(denied or []),
|
|
|
|
|
"field_permissions": {},
|
|
|
|
|
"is_system_admin": is_system_admin,
|
|
|
|
|
"version": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def tenant_id() -> uuid.UUID:
|
|
|
|
|
return uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def user_id() -> uuid.UUID:
|
|
|
|
|
return uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_db() -> AsyncMock:
|
|
|
|
|
return AsyncMock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _mock_hooks():
|
|
|
|
|
"""Patch do_action so the loop doesn't try to fire real hooks."""
|
|
|
|
|
with patch("app.core.hooks.do_action", new_callable=AsyncMock):
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _clean_skill_registry():
|
|
|
|
|
"""Reset the singleton skill registry between tests."""
|
|
|
|
|
registry = get_skill_registry()
|
|
|
|
|
for skill in list(registry.list_all()):
|
|
|
|
|
registry.unregister(skill.name)
|
|
|
|
|
yield
|
|
|
|
|
for skill in list(registry.list_all()):
|
|
|
|
|
registry.unregister(skill.name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 1. ReAct Loop tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReActLoop:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_multi_step_loop_with_3_tool_calls(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Multi-step loop with 3+ tool calls then a final answer."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="Step 1",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search", arguments={"q": "1"})],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="Step 2",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search", arguments={"q": "2"})],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="Step 3",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search", arguments={"q": "3"})],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(content="Final answer", cost_usd=0.001),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Do it"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "completed"
|
|
|
|
|
assert result.steps_taken == 4
|
|
|
|
|
assert len(result.steps) == 4
|
|
|
|
|
assert [s.action for s in result.steps] == ["search", "search", "search", None]
|
|
|
|
|
assert result.final_content == "Final answer"
|
|
|
|
|
assert handler.await_count == 3
|
|
|
|
|
assert mock_llm.await_count == 4
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_graceful_stop_on_max_steps(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Loop stops gracefully when max_steps is reached."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
response = _make_llm_response(
|
|
|
|
|
content="Searching",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search")],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.return_value = response
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Keep going"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
max_steps=3,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "stopped_max_steps"
|
|
|
|
|
assert result.steps_taken == 3
|
|
|
|
|
assert "max_steps" in (result.error or "")
|
|
|
|
|
assert mock_llm.await_count == 3
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_graceful_stop_on_timeout(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Loop stops gracefully when timeout is exceeded."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
registry = _make_tool_registry()
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.return_value = _make_llm_response(content="thinking", cost_usd=0.001)
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
|
|
|
tools=[],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
timeout_seconds=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "stopped_timeout"
|
|
|
|
|
assert "timeout" in (result.error or "").lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transient_error_retry_then_success(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""TRANSIENT errors are retried, then the loop succeeds."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
registry = _make_tool_registry()
|
|
|
|
|
|
|
|
|
|
responses: list[Any] = [
|
|
|
|
|
Exception("rate limit exceeded"),
|
|
|
|
|
_make_llm_response(content="Success after retry", cost_usd=0.002),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
with patch("app.ai.agent_loop.asyncio.sleep", new_callable=AsyncMock):
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
|
|
|
tools=[],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "completed"
|
|
|
|
|
assert result.final_content == "Success after retry"
|
|
|
|
|
assert mock_llm.await_count == 2
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_permanent_error_stops_immediately(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""PERMANENT errors stop the loop immediately (no retry)."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
registry = _make_tool_registry()
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = Exception("authentication error: invalid api key")
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
|
|
|
tools=[],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "stopped_error"
|
|
|
|
|
assert "Permanent error" in (result.error or "")
|
|
|
|
|
assert mock_llm.await_count == 1
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cost_accumulation_across_steps(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Cost accumulates across multiple LLM calls."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="S1", tool_calls=[_make_tool_call(name="search")], cost_usd=0.01
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="S2", tool_calls=[_make_tool_call(name="search")], cost_usd=0.02
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(content="Final", cost_usd=0.03),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Search twice"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "completed"
|
|
|
|
|
assert result.total_cost_usd == pytest.approx(0.06)
|
|
|
|
|
assert result.steps_taken == 3
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_dry_run_does_not_execute_tools(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Dry-run mode returns mock results and never calls tool handlers."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="REAL RESULT")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="Calling search",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search", arguments={"q": "x"})],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(content="Done", cost_usd=0.001),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Search"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
dry_run=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.status == "completed"
|
|
|
|
|
handler.assert_not_awaited()
|
|
|
|
|
obs = result.steps[0].observation
|
|
|
|
|
assert obs is not None
|
|
|
|
|
assert "dry_run" in obs
|
|
|
|
|
assert "REAL RESULT" not in obs
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_audit_log_created_for_each_tool_call(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""An audit log entry is created for every tool call."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="Calling",
|
|
|
|
|
tool_calls=[_make_tool_call(name="search", arguments={"q": "1"})],
|
|
|
|
|
cost_usd=0.001,
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(content="Done", cost_usd=0.001),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
with patch("app.core.audit.log_audit", new_callable=AsyncMock) as mock_audit:
|
|
|
|
|
await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Search"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
agent_run_id=uuid.uuid4(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
mock_audit.assert_awaited_once()
|
|
|
|
|
call_kwargs = mock_audit.await_args.kwargs
|
|
|
|
|
assert call_kwargs["action"] == "agent.tool_call"
|
|
|
|
|
assert call_kwargs["entity_type"] == "agent_run"
|
|
|
|
|
assert call_kwargs["details"]["tool_name"] == "search"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 2. Agent Permission tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAgentPermissions:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_resolve_agent_permissions_intersection(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""resolve_agent_permissions returns the correct intersection."""
|
|
|
|
|
agent = MockAgentDefinition(
|
|
|
|
|
tool_ids=["mail_read", "contact_list"],
|
|
|
|
|
skill_ids=["mail_skill"],
|
|
|
|
|
)
|
|
|
|
|
skill_registry = get_skill_registry()
|
|
|
|
|
skill_registry.register(
|
|
|
|
|
SkillDefinition(
|
|
|
|
|
name="mail_skill",
|
|
|
|
|
description="mail",
|
|
|
|
|
instructions="use mail tools",
|
|
|
|
|
allowed_tool_ids=["mail_read"],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
tool_registry = MockToolRegistry([
|
|
|
|
|
MockTool("mail_read", required_permission="mail:read"),
|
|
|
|
|
MockTool("contact_list"),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
|
|
|
|
with patch(
|
2026-08-23 15:04:51 +02:00
|
|
|
"app.ai.tool_registry.get_tool_registry",
|
2026-08-17 19:36:42 +02:00
|
|
|
return_value=tool_registry,
|
|
|
|
|
):
|
|
|
|
|
ctx = await resolve_agent_permissions(
|
|
|
|
|
mock_db, tenant_id, user_id, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert isinstance(ctx, AgentPermissionContext)
|
|
|
|
|
assert ctx.is_system_admin is False
|
|
|
|
|
assert set(ctx.effective_tool_ids) == {"mail_read", "contact_list"}
|
|
|
|
|
assert ctx.can_use_tool("mail_read") is True
|
|
|
|
|
assert ctx.can_use_tool("contact_list") is True
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_system_admin_gets_all_agent_tools(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""System admins get all tools configured on the agent."""
|
|
|
|
|
agent = MockAgentDefinition(tool_ids=["mail_read", "secret_tool"])
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(is_system_admin=True)
|
|
|
|
|
ctx = await resolve_agent_permissions(
|
|
|
|
|
mock_db, tenant_id, user_id, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert ctx.is_system_admin is True
|
|
|
|
|
assert set(ctx.effective_tool_ids) == {"mail_read", "secret_tool"}
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_non_admin_only_gets_matching_permissions(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""Non-admin only gets tools whose required permission they hold."""
|
|
|
|
|
agent = MockAgentDefinition(tool_ids=["mail_read", "secret_tool"])
|
|
|
|
|
tool_registry = MockToolRegistry([
|
|
|
|
|
MockTool("mail_read", required_permission="mail:read"),
|
|
|
|
|
MockTool("secret_tool", required_permission="admin:all"),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
|
|
|
|
with patch(
|
2026-08-23 15:04:51 +02:00
|
|
|
"app.ai.tool_registry.get_tool_registry",
|
2026-08-17 19:36:42 +02:00
|
|
|
return_value=tool_registry,
|
|
|
|
|
):
|
|
|
|
|
ctx = await resolve_agent_permissions(
|
|
|
|
|
mock_db, tenant_id, user_id, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert set(ctx.effective_tool_ids) == {"mail_read"}
|
|
|
|
|
assert ctx.can_use_tool("secret_tool") is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_filter_visible_agents_respects_agents_read(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""filter_visible_agents returns [] without agents:read."""
|
|
|
|
|
agents = [MockAgentDefinition(), MockAgentDefinition()]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=[])
|
|
|
|
|
visible = await filter_visible_agents(mock_db, tenant_id, user_id, agents)
|
|
|
|
|
|
|
|
|
|
assert visible == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_filter_visible_agents_system_admin_sees_all(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""System admins see all agents."""
|
|
|
|
|
agents = [MockAgentDefinition(), MockAgentDefinition()]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(is_system_admin=True)
|
|
|
|
|
visible = await filter_visible_agents(mock_db, tenant_id, user_id, agents)
|
|
|
|
|
|
|
|
|
|
assert len(visible) == 2
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_filter_visible_agents_filters_by_visible_ids(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""filter_visible_agents filters by get_visible_ids when agents:read is held."""
|
|
|
|
|
a1 = MockAgentDefinition()
|
|
|
|
|
a2 = MockAgentDefinition()
|
|
|
|
|
agents = [a1, a2]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=["agents:read"])
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.permission_resolver.get_visible_ids",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_visible:
|
|
|
|
|
mock_visible.return_value = ({a1.id}, {a1.id: "read"})
|
|
|
|
|
visible = await filter_visible_agents(mock_db, tenant_id, user_id, agents)
|
|
|
|
|
|
|
|
|
|
assert visible == [a1]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_check_agent_execute_permission_respects_agents_execute(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""check_agent_execute_permission returns False without agents:execute."""
|
|
|
|
|
agent_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=[])
|
|
|
|
|
allowed = await check_agent_execute_permission(
|
|
|
|
|
mock_db, tenant_id, user_id, agent_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert allowed is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_check_agent_execute_permission_system_admin(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""System admins always pass agents:execute."""
|
|
|
|
|
agent_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(is_system_admin=True)
|
|
|
|
|
allowed = await check_agent_execute_permission(
|
|
|
|
|
mock_db, tenant_id, user_id, agent_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert allowed is True
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_check_agent_execute_permission_entity_access(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""With agents:execute, entity access decides."""
|
|
|
|
|
agent_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.agent_permissions.resolve_permissions",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_resolve:
|
|
|
|
|
mock_resolve.return_value = _permissions(permissions=["agents:execute"])
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.permission_resolver.check_entity_access",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_access:
|
|
|
|
|
mock_access.return_value = True
|
|
|
|
|
allowed = await check_agent_execute_permission(
|
|
|
|
|
mock_db, tenant_id, user_id, agent_id
|
|
|
|
|
)
|
|
|
|
|
assert allowed is True
|
|
|
|
|
mock_access.return_value = False
|
|
|
|
|
denied = await check_agent_execute_permission(
|
|
|
|
|
mock_db, tenant_id, user_id, agent_id
|
|
|
|
|
)
|
|
|
|
|
assert denied is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_optimistic_lock_detects_version_mismatch(self, tenant_id, mock_db):
|
|
|
|
|
"""check_entity_lock raises ApiError conflict on version mismatch."""
|
|
|
|
|
entity_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import column
|
|
|
|
|
|
|
|
|
|
class MockModel:
|
|
|
|
|
version = column("version")
|
|
|
|
|
id = column("id")
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.entity_permission_service.ENTITY_MODELS",
|
|
|
|
|
{"agent_definition": MockModel},
|
|
|
|
|
):
|
|
|
|
|
# Version match → passes
|
|
|
|
|
result = MagicMock()
|
|
|
|
|
result.scalar_one_or_none.return_value = 3
|
|
|
|
|
mock_db.execute.return_value = result
|
|
|
|
|
ok = await check_entity_lock(mock_db, "agent_definition", entity_id, 3)
|
|
|
|
|
assert ok is True
|
|
|
|
|
|
|
|
|
|
# Version mismatch → conflict
|
|
|
|
|
result.scalar_one_or_none.return_value = 5
|
|
|
|
|
with pytest.raises(ApiError) as exc_info:
|
|
|
|
|
await check_entity_lock(mock_db, "agent_definition", entity_id, 3)
|
|
|
|
|
assert exc_info.value.code == "conflict"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_optimistic_lock_no_version_column_is_noop(self, tenant_id, mock_db):
|
|
|
|
|
"""Models without a version column are treated as unlocked."""
|
|
|
|
|
entity_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
class NoVersionModel:
|
|
|
|
|
id = "id"
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.entity_permission_service.ENTITY_MODELS",
|
|
|
|
|
{"agent_definition": NoVersionModel},
|
|
|
|
|
):
|
|
|
|
|
ok = await check_entity_lock(mock_db, "agent_definition", entity_id, 1)
|
|
|
|
|
assert ok is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 3. Approval tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApproval:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_approval_request(self, tenant_id, mock_db):
|
|
|
|
|
"""create_approval_request creates a pending request."""
|
|
|
|
|
entity_id = uuid.uuid4()
|
|
|
|
|
requested_by = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
req = await create_approval_request(
|
|
|
|
|
mock_db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
entity_type="agent_definition",
|
|
|
|
|
entity_id=entity_id,
|
|
|
|
|
action="execute",
|
|
|
|
|
requested_by=requested_by,
|
|
|
|
|
requested_by_type="agent",
|
|
|
|
|
metadata={"agent_run_id": "run-1"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert isinstance(req, ApprovalRequest)
|
|
|
|
|
assert req.status == "pending"
|
|
|
|
|
assert req.entity_type == "agent_definition"
|
|
|
|
|
assert req.entity_id == entity_id
|
|
|
|
|
assert req.action == "execute"
|
|
|
|
|
assert req.requested_by == requested_by
|
|
|
|
|
assert req.request_metadata == {"agent_run_id": "run-1"}
|
|
|
|
|
mock_db.add.assert_called_once()
|
|
|
|
|
mock_db.flush.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_approve_approval_request(self, tenant_id, mock_db):
|
2026-09-18 11:15:45 +02:00
|
|
|
"""Superseded by TestF11ApprovalBinding (real DB, deterministic)."""
|
|
|
|
|
pytest.skip("F11 contract covered by tests/test_s1_security_guards.py::TestF11ApprovalBinding with a real database")
|
2026-08-17 19:36:42 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reject_approval_request(self, tenant_id, mock_db):
|
2026-09-18 11:15:45 +02:00
|
|
|
"""Superseded by TestF11ApprovalBinding (real DB, deterministic)."""
|
|
|
|
|
pytest.skip("F11 contract covered by tests/test_s1_security_guards.py::TestF11ApprovalBinding with a real database")
|
2026-08-17 19:36:42 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_expire_approval_request(self, tenant_id, mock_db):
|
|
|
|
|
"""expire_approval_request marks a pending request as expired."""
|
|
|
|
|
request_id = uuid.uuid4()
|
|
|
|
|
req = ApprovalRequest(
|
|
|
|
|
id=request_id,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
entity_type="agent_definition",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
action="execute",
|
|
|
|
|
requested_by=uuid.uuid4(),
|
|
|
|
|
status="pending",
|
|
|
|
|
)
|
|
|
|
|
result = MagicMock()
|
|
|
|
|
result.scalar_one_or_none.return_value = req
|
|
|
|
|
mock_db.execute.return_value = result
|
|
|
|
|
|
|
|
|
|
updated = await expire_approval_request(mock_db, tenant_id, request_id)
|
|
|
|
|
|
|
|
|
|
assert updated is req
|
|
|
|
|
assert req.status == "expired"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_resolve_non_pending_returns_none(self, tenant_id, mock_db):
|
2026-09-18 11:15:45 +02:00
|
|
|
"""Superseded: F11 changed the contract to ApprovalDecisionError(409)."""
|
|
|
|
|
pytest.skip("F11 contract covered by tests/test_s1_security_guards.py::TestF11ApprovalBinding (not_pending 409) with a real database")
|
2026-08-17 19:36:42 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_approvals_with_filters(self, tenant_id, mock_db):
|
|
|
|
|
"""List approvals applies status/entity filters via SQLAlchemy query."""
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
# Build the same query the route uses and verify filters are applied.
|
|
|
|
|
query = select(ApprovalRequest).where(ApprovalRequest.tenant_id == tenant_id)
|
|
|
|
|
query = query.where(ApprovalRequest.status == "pending")
|
|
|
|
|
query = query.where(ApprovalRequest.entity_type == "agent_definition")
|
|
|
|
|
|
|
|
|
|
compiled = str(query)
|
|
|
|
|
assert "approval_requests" in compiled
|
|
|
|
|
assert "status" in compiled
|
|
|
|
|
assert "entity_type" in compiled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 4. Skill tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSkills:
|
|
|
|
|
def test_skill_definition_registration(self):
|
|
|
|
|
"""SkillDefinition registers and is retrievable."""
|
|
|
|
|
registry = get_skill_registry()
|
|
|
|
|
skill = SkillDefinition(
|
|
|
|
|
name="mail_reader",
|
|
|
|
|
description="Read emails",
|
|
|
|
|
instructions="Use mail tools",
|
|
|
|
|
allowed_tool_ids=["mail_read"],
|
|
|
|
|
category="mail",
|
|
|
|
|
)
|
|
|
|
|
registry.register(skill)
|
|
|
|
|
assert registry.get("mail_reader") is skill
|
|
|
|
|
assert skill.to_dict()["category"] == "mail"
|
|
|
|
|
|
|
|
|
|
def test_get_by_names_skips_unknown(self):
|
|
|
|
|
"""get_by_names skips unknown skill names."""
|
|
|
|
|
registry = get_skill_registry()
|
|
|
|
|
registry.register(SkillDefinition(name="a", description="d", instructions="i"))
|
|
|
|
|
registry.register(SkillDefinition(name="b", description="d", instructions="i"))
|
|
|
|
|
resolved = registry.get_by_names(["a", "b", "missing"])
|
|
|
|
|
assert {s.name for s in resolved} == {"a", "b"}
|
|
|
|
|
|
|
|
|
|
def test_skills_never_grant_permissions_not_in_user_role(self):
|
|
|
|
|
"""A skill cannot grant a tool the user lacks permission for."""
|
|
|
|
|
from app.ai.agent_tools import get_agent_tools
|
|
|
|
|
|
|
|
|
|
registry = MockToolRegistry([
|
|
|
|
|
MockTool("mail_read", required_permission="mail:read"),
|
|
|
|
|
])
|
|
|
|
|
skill_registry = get_skill_registry()
|
|
|
|
|
skill_registry.register(
|
|
|
|
|
SkillDefinition(
|
|
|
|
|
name="mail_skill",
|
|
|
|
|
description="mail",
|
|
|
|
|
instructions="use mail",
|
|
|
|
|
allowed_tool_ids=["mail_read"],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
agent = MockAgentDefinition(tool_ids=["mail_read"], skill_ids=["mail_skill"])
|
|
|
|
|
|
|
|
|
|
# User does NOT have mail:read
|
|
|
|
|
schemas, skills = get_agent_tools(
|
|
|
|
|
agent, registry, skill_registry, _permissions(permissions=[])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert schemas == []
|
|
|
|
|
assert len(skills) == 1 # skill resolved but grants no tools
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 5. Context builder tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestContextBuilder:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_build_agent_context_includes_system_prompt(self, tenant_id, user_id):
|
|
|
|
|
"""build_agent_context includes the agent's system prompt."""
|
|
|
|
|
agent = MockAgentDefinition(name="SalesBot", system_prompt="You are SalesBot.")
|
|
|
|
|
messages = await build_agent_context(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
user_message="Hello",
|
|
|
|
|
db=None,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
system_content = messages[0]["content"]
|
|
|
|
|
assert "You are SalesBot." in system_content
|
|
|
|
|
assert messages[-1] == {"role": "user", "content": "Hello"}
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_react_format_instructions_present(self, tenant_id, user_id):
|
|
|
|
|
"""The system prompt contains ReAct format instructions."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
messages = await build_agent_context(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
user_message="Hi",
|
|
|
|
|
db=None,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
system_content = messages[0]["content"]
|
|
|
|
|
assert "Thought:" in system_content
|
|
|
|
|
assert "Action:" in system_content
|
|
|
|
|
assert "Final Answer:" in system_content
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_sensitive_fields_excluded(self, tenant_id, user_id):
|
|
|
|
|
"""Sensitive fields (api_key) are not included in the built context."""
|
|
|
|
|
agent = MockAgentDefinition(
|
|
|
|
|
name="Agent",
|
|
|
|
|
system_prompt="You are an agent.",
|
|
|
|
|
api_key="sk-super-secret-key-12345",
|
|
|
|
|
)
|
|
|
|
|
messages = await build_agent_context(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
user_message="Hi",
|
|
|
|
|
db=None,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
all_content = "\n".join(m["content"] for m in messages)
|
|
|
|
|
assert "sk-super-secret-key-12345" not in all_content
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_tool_descriptions_included(self, tenant_id, user_id):
|
|
|
|
|
"""Tool descriptions are included in the system prompt."""
|
|
|
|
|
agent = MockAgentDefinition(tool_ids=["mail_read"])
|
|
|
|
|
tool_registry = MockToolRegistry([
|
|
|
|
|
MockTool("mail_read", description="Read emails from the mailbox"),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
with patch(
|
2026-08-23 15:04:51 +02:00
|
|
|
"app.ai.tool_registry.get_tool_registry",
|
2026-08-17 19:36:42 +02:00
|
|
|
return_value=tool_registry,
|
|
|
|
|
):
|
|
|
|
|
messages = await build_agent_context(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
user_message="Hi",
|
|
|
|
|
db=None,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
system_content = messages[0]["content"]
|
|
|
|
|
assert "mail_read" in system_content
|
|
|
|
|
assert "Read emails from the mailbox" in system_content
|
|
|
|
|
|
|
|
|
|
def test_react_system_prompt_builder_constraints(self):
|
|
|
|
|
"""ReActSystemPromptBuilder includes max_steps and budget constraints."""
|
|
|
|
|
agent = MockAgentDefinition(max_steps=5, budget_limit_usd=2.5)
|
|
|
|
|
builder = ReActSystemPromptBuilder(agent_definition=agent)
|
|
|
|
|
prompt = builder.build()
|
|
|
|
|
assert "Maximum 5 reasoning steps per run." in prompt
|
|
|
|
|
assert "Budget limit: $2.50 per run." in prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 6. Data policy tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDataPolicy:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enforce_data_policy_removes_sensitive_fields(self, tenant_id):
|
|
|
|
|
"""enforce_data_policy strips sensitive fields from dict content."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": {
|
|
|
|
|
"email": "a@b.com",
|
|
|
|
|
"smtp_password": "secret",
|
|
|
|
|
"imap_password": "secret2",
|
|
|
|
|
"api_key": "sk-123",
|
|
|
|
|
"name": "Alice",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.llm_client.get_provider_compliance",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_compliance:
|
|
|
|
|
mock_compliance.return_value = None
|
|
|
|
|
filtered = await enforce_data_policy(
|
|
|
|
|
mock_db := AsyncMock(), tenant_id, messages, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
content = filtered[0]["content"]
|
|
|
|
|
assert "smtp_password" not in content
|
|
|
|
|
assert "imap_password" not in content
|
|
|
|
|
assert "api_key" not in content
|
|
|
|
|
assert content["email"] == "a@b.com"
|
|
|
|
|
assert content["name"] == "Alice"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enforce_data_policy_provider_compliance(self, tenant_id):
|
|
|
|
|
"""Provider compliance blocks fields the provider may not process."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": {
|
|
|
|
|
"email": "a@b.com",
|
|
|
|
|
"smtp_password": "secret",
|
|
|
|
|
"notes": "internal note",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.llm_client.get_provider_compliance",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_compliance:
|
|
|
|
|
# Provider only allowed to process 'internal' data — critical fields blocked
|
|
|
|
|
mock_compliance.return_value = {"allowed_data_classes": ["internal"]}
|
|
|
|
|
filtered = await enforce_data_policy(
|
|
|
|
|
AsyncMock(), tenant_id, messages, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
content = filtered[0]["content"]
|
|
|
|
|
# smtp_password is critical → blocked
|
|
|
|
|
assert "smtp_password" not in content
|
|
|
|
|
# email is guessed as 'internal' (no sensitive marker) → kept
|
|
|
|
|
assert content.get("email") == "a@b.com"
|
|
|
|
|
# notes is internal → kept
|
|
|
|
|
assert content.get("notes") == "internal note"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enforce_data_policy_allowed_categories(self, tenant_id):
|
|
|
|
|
"""Allowed data categories filter disallowed entity payloads."""
|
|
|
|
|
agent = MockAgentDefinition(
|
|
|
|
|
ai_use_case_metadata={"data_categories": ["contact_data"]}
|
|
|
|
|
)
|
|
|
|
|
messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": {
|
|
|
|
|
"first_name": "Alice",
|
|
|
|
|
"last_name": "Smith",
|
|
|
|
|
"company_id": "c1",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.llm_client.get_provider_compliance",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_compliance:
|
|
|
|
|
mock_compliance.return_value = None
|
|
|
|
|
filtered = await enforce_data_policy(
|
|
|
|
|
AsyncMock(), tenant_id, messages, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# contact_data is allowed → contact payload kept
|
|
|
|
|
content = filtered[0]["content"]
|
|
|
|
|
assert content.get("first_name") == "Alice"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enforce_data_policy_blocks_disallowed_category(self, tenant_id):
|
|
|
|
|
"""Disallowed data categories are removed."""
|
|
|
|
|
agent = MockAgentDefinition(
|
|
|
|
|
ai_use_case_metadata={"data_categories": ["contact_data"]}
|
|
|
|
|
)
|
|
|
|
|
messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": {
|
|
|
|
|
"email": "a@b.com",
|
|
|
|
|
"smtp_password": "secret",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.ai.llm_client.get_provider_compliance",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
) as mock_compliance:
|
|
|
|
|
mock_compliance.return_value = None
|
|
|
|
|
filtered = await enforce_data_policy(
|
|
|
|
|
AsyncMock(), tenant_id, messages, agent
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# mail_account payload is not in allowed categories → removed
|
|
|
|
|
content = filtered[0]["content"]
|
|
|
|
|
assert content == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 7. Transparency tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTransparency:
|
|
|
|
|
def test_mark_as_ai_generated_adds_metadata(self):
|
|
|
|
|
"""mark_as_ai_generated adds ai_generated flag and metadata."""
|
|
|
|
|
result = mark_as_ai_generated(
|
|
|
|
|
"Hello from AI",
|
|
|
|
|
{"model": "gpt-4o", "provider": "openai"},
|
|
|
|
|
)
|
|
|
|
|
assert result["content"] == "Hello from AI"
|
|
|
|
|
assert result["ai_generated"] is True
|
|
|
|
|
assert result["ai_metadata"]["model"] == "gpt-4o"
|
|
|
|
|
assert result["ai_metadata"]["provider"] == "openai"
|
|
|
|
|
assert "timestamp" in result["ai_metadata"]
|
|
|
|
|
|
|
|
|
|
def test_mark_as_ai_generated_defaults(self):
|
|
|
|
|
"""mark_as_ai_generated uses unknown defaults when metadata is empty."""
|
|
|
|
|
result = mark_as_ai_generated("content")
|
|
|
|
|
assert result["ai_metadata"]["model"] == "unknown"
|
|
|
|
|
assert result["ai_metadata"]["provider"] == "unknown"
|
|
|
|
|
|
|
|
|
|
def test_is_ai_participant_detects_ai(self):
|
|
|
|
|
"""is_ai_participant detects AI participant types."""
|
|
|
|
|
assert is_ai_participant("a1", "agent") is True
|
|
|
|
|
assert is_ai_participant("a2", "ai") is True
|
|
|
|
|
assert is_ai_participant("a3", "system_ai") is True
|
|
|
|
|
assert is_ai_participant("u1", "user") is False
|
|
|
|
|
assert is_ai_participant("u2", "human") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 9. Budget limit tests
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBudgetLimits:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_agent_run_stops_when_budget_exceeded(self):
|
|
|
|
|
"""run_agent returns budget_exceeded when cumulative cost >= budget."""
|
|
|
|
|
from app.plugins.builtins.automation.agent_runner import run_agent
|
|
|
|
|
|
|
|
|
|
agent_id = uuid.uuid4()
|
|
|
|
|
agent = MagicMock()
|
|
|
|
|
agent.id = agent_id
|
|
|
|
|
agent.is_active = True
|
|
|
|
|
agent.max_executions_per_hour = 0
|
|
|
|
|
agent.budget_limit_usd = 1.0
|
|
|
|
|
agent.tenant_id = uuid.uuid4()
|
|
|
|
|
agent.created_by = uuid.uuid4()
|
|
|
|
|
agent.mode = "reactive"
|
|
|
|
|
agent.tool_ids = []
|
|
|
|
|
agent.skill_ids = []
|
|
|
|
|
agent.name = "Budget Agent"
|
|
|
|
|
agent.max_duration_seconds = 300
|
|
|
|
|
|
|
|
|
|
# Mock session factory: first query returns agent, second returns cost sum
|
|
|
|
|
mock_db = AsyncMock()
|
|
|
|
|
|
|
|
|
|
def _execute_side_effect(query):
|
|
|
|
|
result = MagicMock()
|
|
|
|
|
if "agent_definitions" in str(query).lower():
|
|
|
|
|
result.scalar_one_or_none.return_value = agent
|
|
|
|
|
else:
|
|
|
|
|
# cost sum query
|
|
|
|
|
result.scalar.return_value = 1.5 # >= budget 1.0
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
mock_db.execute.side_effect = _execute_side_effect
|
|
|
|
|
|
|
|
|
|
class MockFactory:
|
|
|
|
|
def __init__(self, db):
|
|
|
|
|
self._db = db
|
|
|
|
|
|
|
|
|
|
async def __aenter__(self):
|
|
|
|
|
return self._db
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, *args):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
factory = MagicMock()
|
|
|
|
|
factory.return_value = MockFactory(mock_db)
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"app.plugins.builtins.automation.agent_runner.get_session_factory",
|
|
|
|
|
return_value=factory,
|
|
|
|
|
):
|
|
|
|
|
result = await run_agent(
|
|
|
|
|
ctx={"tenant_id": str(agent.tenant_id), "user_id": str(agent.created_by)},
|
|
|
|
|
agent_id=str(agent_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["status"] == "budget_exceeded"
|
|
|
|
|
assert "Budget limit exceeded" in result["error"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cost_tracking_accumulates_in_react_loop(self, tenant_id, user_id, mock_db):
|
|
|
|
|
"""ReAct loop cost tracking accumulates across steps (budget input)."""
|
|
|
|
|
agent = MockAgentDefinition()
|
|
|
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
|
registry = _make_tool_registry({"search": handler})
|
|
|
|
|
|
|
|
|
|
responses = [
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="S1", tool_calls=[_make_tool_call(name="search")], cost_usd=0.4
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="S2", tool_calls=[_make_tool_call(name="search")], cost_usd=0.4
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(
|
|
|
|
|
content="S3", tool_calls=[_make_tool_call(name="search")], cost_usd=0.4
|
|
|
|
|
),
|
|
|
|
|
_make_llm_response(content="Final", cost_usd=0.1),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.agent_loop.llm_complete", new_callable=AsyncMock) as mock_llm:
|
|
|
|
|
mock_llm.side_effect = responses
|
|
|
|
|
result = await run_react_loop(
|
|
|
|
|
agent_definition=agent,
|
|
|
|
|
messages=[{"role": "user", "content": "Run"}],
|
|
|
|
|
tools=[{"type": "function", "function": {"name": "search"}}],
|
|
|
|
|
tool_registry=registry,
|
|
|
|
|
db=mock_db,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 0.4 + 0.4 + 0.4 + 0.1 = 1.3 — exceeds a 1.0 budget
|
|
|
|
|
assert result.total_cost_usd == pytest.approx(1.3)
|
|
|
|
|
assert result.total_cost_usd > 1.0
|