2026-08-19 00:18:07 +02:00
|
|
|
"""Tests for Phase I — Integration tools: I-AW, I-AK."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
import uuid
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntegrationTools:
|
|
|
|
|
"""Test the integration tools module (I-AW, I-AK)."""
|
|
|
|
|
|
|
|
|
|
def test_all_tools_importable(self):
|
|
|
|
|
"""All integration tools are importable."""
|
|
|
|
|
from app.ai.integration_tools import (
|
|
|
|
|
start_workflow_tool,
|
|
|
|
|
check_workflow_status_tool,
|
|
|
|
|
ask_knowledge_tool,
|
|
|
|
|
search_knowledge_tool,
|
|
|
|
|
register_integration_tools,
|
|
|
|
|
)
|
|
|
|
|
assert callable(start_workflow_tool)
|
|
|
|
|
assert callable(check_workflow_status_tool)
|
|
|
|
|
assert callable(ask_knowledge_tool)
|
|
|
|
|
assert callable(search_knowledge_tool)
|
|
|
|
|
assert callable(register_integration_tools)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_start_workflow_tool_returns_result(self):
|
|
|
|
|
"""start_workflow_tool returns instance info on success."""
|
|
|
|
|
from app.ai.integration_tools import start_workflow_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.services.workflow_service.create_instance", new_callable=AsyncMock) as mock_create:
|
|
|
|
|
mock_create.return_value = {"id": "inst-123", "status": "pending"}
|
|
|
|
|
result = await start_workflow_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
workflow_id="wf-123", context={"key": "value"},
|
|
|
|
|
)
|
|
|
|
|
assert result["instance_id"] == "inst-123"
|
|
|
|
|
assert result["status"] == "pending"
|
|
|
|
|
assert result["workflow_id"] == "wf-123"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_start_workflow_tool_handles_not_found(self):
|
|
|
|
|
"""start_workflow_tool returns error when workflow not found."""
|
|
|
|
|
from app.ai.integration_tools import start_workflow_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.services.workflow_service.create_instance", new_callable=AsyncMock) as mock_create:
|
|
|
|
|
mock_create.return_value = None
|
|
|
|
|
result = await start_workflow_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
workflow_id="nonexistent",
|
|
|
|
|
)
|
|
|
|
|
assert result["error"] == "Workflow not found"
|
|
|
|
|
assert result["status"] == "not_found"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ask_knowledge_tool_delegates_to_ask_knowledge(self):
|
|
|
|
|
"""ask_knowledge_tool delegates to knowledge_lifecycle.ask_knowledge."""
|
|
|
|
|
from app.ai.integration_tools import ask_knowledge_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.knowledge_lifecycle.ask_knowledge", new_callable=AsyncMock) as mock_ask:
|
|
|
|
|
mock_ask.return_value = {"answer": "Test answer", "evidence": [], "query": "test"}
|
|
|
|
|
result = await ask_knowledge_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
query="test query",
|
|
|
|
|
)
|
|
|
|
|
assert result["answer"] == "Test answer"
|
|
|
|
|
mock_ask.assert_called_once()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_knowledge_tool_handles_no_search(self):
|
|
|
|
|
"""search_knowledge_tool returns error when search not available."""
|
|
|
|
|
from app.ai.integration_tools import search_knowledge_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.plugins.builtins.unified_search.contracts.UnifiedSearchContract") as mock_contract:
|
|
|
|
|
mock_contract.get_function = MagicMock(return_value=None)
|
|
|
|
|
result = await search_knowledge_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
query="test",
|
|
|
|
|
)
|
|
|
|
|
assert result["error"] == "Search not available"
|
|
|
|
|
assert result["results"] == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_check_workflow_status_tool_handles_not_found(self):
|
|
|
|
|
"""check_workflow_status_tool returns error when instance not found."""
|
|
|
|
|
from app.ai.integration_tools import check_workflow_status_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.services.workflow_service.get_instance", new_callable=AsyncMock) as mock_get:
|
|
|
|
|
mock_get.return_value = None
|
|
|
|
|
result = await check_workflow_status_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(),
|
|
|
|
|
instance_id=str(uuid.uuid4()),
|
|
|
|
|
)
|
|
|
|
|
assert result["error"] == "Instance not found"
|
|
|
|
|
assert result["status"] == "not_found"
|
2026-08-19 00:20:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── I-APPR-LOOP: Agent Loop Human-in-the-Loop Approval ──────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAgentLoopApproval:
|
|
|
|
|
"""Test the I-APPR-LOOP approval integration in run_react_loop."""
|
|
|
|
|
|
|
|
|
|
def test_run_react_loop_has_approval_params(self):
|
|
|
|
|
"""run_react_loop has require_approval and approval_tools parameters."""
|
|
|
|
|
import inspect
|
|
|
|
|
from app.ai.agent_loop import run_react_loop
|
|
|
|
|
|
|
|
|
|
sig = inspect.signature(run_react_loop)
|
|
|
|
|
assert "require_approval" in sig.parameters
|
|
|
|
|
assert "approval_tools" in sig.parameters
|
|
|
|
|
assert sig.parameters["require_approval"].default is False
|
|
|
|
|
assert sig.parameters["approval_tools"].default is None
|
|
|
|
|
|
|
|
|
|
def test_react_result_has_waiting_for_approval_status(self):
|
|
|
|
|
"""ReActResult supports waiting_for_approval status."""
|
|
|
|
|
from app.ai.agent_loop import ReActResult
|
|
|
|
|
|
|
|
|
|
result = ReActResult(final_content="", status="waiting_for_approval")
|
|
|
|
|
assert result.status == "waiting_for_approval"
|
|
|
|
|
assert result.final_content == ""
|
2026-08-19 00:26:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── I-MCP: MCP-Exposure ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMCPExposure:
|
|
|
|
|
"""Test the MCP exposure layer (I-MCP)."""
|
|
|
|
|
|
|
|
|
|
def test_mcp_tools_count(self):
|
|
|
|
|
"""6 MCP tools are defined."""
|
|
|
|
|
from app.ai.mcp_exposure import MCP_TOOLS
|
|
|
|
|
|
|
|
|
|
assert len(MCP_TOOLS) == 6
|
|
|
|
|
|
|
|
|
|
def test_mcp_tool_names(self):
|
|
|
|
|
"""MCP tools have correct names."""
|
|
|
|
|
from app.ai.mcp_exposure import MCP_TOOLS
|
|
|
|
|
|
|
|
|
|
names = {t["name"] for t in MCP_TOOLS}
|
|
|
|
|
assert names == {"search", "ask_knowledge", "start_workflow", "check_workflow_status", "list_agents", "create_task"}
|
|
|
|
|
|
|
|
|
|
def test_get_mcp_tools_returns_schemas(self):
|
|
|
|
|
"""get_mcp_tools returns tool schemas without internal fields."""
|
|
|
|
|
from app.ai.mcp_exposure import get_mcp_tools
|
|
|
|
|
|
|
|
|
|
tools = get_mcp_tools()
|
|
|
|
|
for t in tools:
|
|
|
|
|
assert "name" in t
|
|
|
|
|
assert "description" in t
|
|
|
|
|
assert "input_schema" in t
|
|
|
|
|
assert "required_permission" not in t # Internal field not exposed
|
|
|
|
|
assert "handler" not in t # Internal field not exposed
|
|
|
|
|
|
|
|
|
|
def test_get_mcp_tool_existing(self):
|
|
|
|
|
"""get_mcp_tool returns tool definition for existing tool."""
|
|
|
|
|
from app.ai.mcp_exposure import get_mcp_tool
|
|
|
|
|
|
|
|
|
|
tool = get_mcp_tool("search")
|
|
|
|
|
assert tool is not None
|
|
|
|
|
assert tool["name"] == "search"
|
|
|
|
|
assert tool["required_permission"] == "contacts:read"
|
|
|
|
|
|
|
|
|
|
def test_get_mcp_tool_nonexistent(self):
|
|
|
|
|
"""get_mcp_tool returns None for unknown tool."""
|
|
|
|
|
from app.ai.mcp_exposure import get_mcp_tool
|
|
|
|
|
|
|
|
|
|
assert get_mcp_tool("nonexistent") is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_mcp_tool_unknown(self):
|
|
|
|
|
"""execute_mcp_tool returns error for unknown tool."""
|
|
|
|
|
from app.ai.mcp_exposure import execute_mcp_tool
|
|
|
|
|
|
|
|
|
|
result = await execute_mcp_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
tool_name="nonexistent", arguments={},
|
|
|
|
|
)
|
|
|
|
|
assert result["status"] == "not_found"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_mcp_tool_permission_denied(self):
|
|
|
|
|
"""execute_mcp_tool returns forbidden when permission missing."""
|
|
|
|
|
from app.ai.mcp_exposure import execute_mcp_tool
|
|
|
|
|
|
|
|
|
|
result = await execute_mcp_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
tool_name="start_workflow", arguments={"workflow_id": "test"},
|
|
|
|
|
user_permissions={"permissions": [], "denied_permissions": [], "is_system_admin": False},
|
|
|
|
|
)
|
|
|
|
|
assert result["status"] == "forbidden"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_mcp_tool_search(self):
|
|
|
|
|
"""execute_mcp_tool delegates to search_knowledge_tool for 'search'."""
|
|
|
|
|
from app.ai.mcp_exposure import execute_mcp_tool
|
|
|
|
|
|
|
|
|
|
with patch("app.ai.integration_tools.search_knowledge_tool", new_callable=AsyncMock) as mock_search:
|
|
|
|
|
mock_search.return_value = {"results": [], "total": 0, "query": "test"}
|
|
|
|
|
result = await execute_mcp_tool(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
tool_name="search", arguments={"query": "test"},
|
|
|
|
|
user_permissions={"is_system_admin": True},
|
|
|
|
|
)
|
|
|
|
|
assert result["query"] == "test"
|
|
|
|
|
mock_search.assert_called_once()
|
2026-08-19 00:30:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── I-WORK-BASE/ACTOR/HANDOFF: Workstream Contract ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWorkstreamContract:
|
|
|
|
|
"""Test the workstream contract module (I-WORK-BASE, I-WORK-ACTOR, I-WORK-HANDOFF)."""
|
|
|
|
|
|
|
|
|
|
def test_workstream_block_dataclass(self):
|
|
|
|
|
"""WorkstreamBlock dataclass works correctly."""
|
|
|
|
|
from app.ai.workstream_contract import WorkstreamBlock
|
|
|
|
|
block = WorkstreamBlock(type="text", content="Hello", metadata={"key": "value"})
|
|
|
|
|
assert block.type == "text"
|
|
|
|
|
assert block.content == "Hello"
|
|
|
|
|
d = block.to_dict()
|
|
|
|
|
assert d["type"] == "text"
|
|
|
|
|
assert d["content"] == "Hello"
|
|
|
|
|
|
|
|
|
|
def test_workstream_message_dataclass(self):
|
|
|
|
|
"""WorkstreamMessage dataclass works correctly."""
|
|
|
|
|
from app.ai.workstream_contract import WorkstreamMessage, WorkstreamBlock
|
|
|
|
|
msg = WorkstreamMessage(actor_type="agent", actor_id="abc-123", content="Test", blocks=[WorkstreamBlock(type="text")])
|
|
|
|
|
assert msg.actor_type == "agent"
|
|
|
|
|
assert msg.actor_id == "abc-123"
|
|
|
|
|
d = msg.to_dict()
|
|
|
|
|
assert d["actor_type"] == "agent"
|
|
|
|
|
assert len(d["blocks"]) == 1
|
|
|
|
|
|
|
|
|
|
def test_build_entity_card(self):
|
|
|
|
|
"""build_entity_card creates correct block."""
|
|
|
|
|
from app.ai.workstream_contract import build_entity_card
|
|
|
|
|
block = build_entity_card("contact", "123", "John Doe", "CEO", "/contacts/123")
|
|
|
|
|
assert block.type == "entity_card"
|
|
|
|
|
assert block.metadata["entity_type"] == "contact"
|
|
|
|
|
assert block.metadata["title"] == "John Doe"
|
|
|
|
|
|
|
|
|
|
def test_build_action_card(self):
|
|
|
|
|
"""build_action_card creates correct block."""
|
|
|
|
|
from app.ai.workstream_contract import build_action_card
|
|
|
|
|
block = build_action_card("Approve?", [{"label": "Yes", "action": "approve"}], "Please approve")
|
|
|
|
|
assert block.type == "action_card"
|
|
|
|
|
assert len(block.metadata["actions"]) == 1
|
|
|
|
|
|
|
|
|
|
def test_build_evidence_card(self):
|
|
|
|
|
"""build_evidence_card creates correct block."""
|
|
|
|
|
from app.ai.workstream_contract import build_evidence_card
|
|
|
|
|
block = build_evidence_card("wiki", "456", "Article", "Snippet", "/wiki/456", 0.9)
|
|
|
|
|
assert block.type == "evidence_card"
|
|
|
|
|
assert block.metadata["confidence"] == 0.9
|
|
|
|
|
|
|
|
|
|
def test_build_approval_card(self):
|
|
|
|
|
"""build_approval_card creates correct block."""
|
|
|
|
|
from app.ai.workstream_contract import build_approval_card
|
|
|
|
|
block = build_approval_card("appr-123", "send_email", "Please approve")
|
|
|
|
|
assert block.type == "approval_card"
|
|
|
|
|
assert block.metadata["approval_id"] == "appr-123"
|
|
|
|
|
|
|
|
|
|
def test_build_miniapp_block(self):
|
|
|
|
|
"""build_miniapp_block creates correct block."""
|
|
|
|
|
from app.ai.workstream_contract import build_miniapp_block
|
|
|
|
|
block = build_miniapp_block("calendar-app", "Calendar", {"type": "form"})
|
|
|
|
|
assert block.type == "miniapp"
|
|
|
|
|
assert block.metadata["app_id"] == "calendar-app"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_post_to_workstream_fallback(self):
|
|
|
|
|
"""post_to_workstream falls back to notification when CommContract unavailable."""
|
|
|
|
|
from app.ai.workstream_contract import post_to_workstream, WorkstreamMessage, WorkstreamBlock
|
|
|
|
|
|
|
|
|
|
with patch("app.core.notifications.post_system_message", new_callable=AsyncMock):
|
|
|
|
|
result = await post_to_workstream(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(),
|
|
|
|
|
message=WorkstreamMessage(actor_type="human", actor_id=str(uuid.uuid4()), content="Test", blocks=[WorkstreamBlock(type="text")]),
|
|
|
|
|
)
|
|
|
|
|
assert result is None # Fallback
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_handoff_creates_task(self):
|
|
|
|
|
"""create_handoff creates a Task with task_type='handoff'."""
|
|
|
|
|
from app.ai.workstream_contract import create_handoff
|
|
|
|
|
|
|
|
|
|
with patch("app.plugins.builtins.tasks.services.create_task", new_callable=AsyncMock) as mock_create:
|
|
|
|
|
mock_create.return_value = {"id": "task-123", "title": "Handoff: review_needed"}
|
|
|
|
|
with patch("app.ai.workstream_contract.post_to_workstream", new_callable=AsyncMock):
|
|
|
|
|
result = await create_handoff(
|
|
|
|
|
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
|
|
|
|
|
handoff_type="review_needed", description="Please review",
|
|
|
|
|
)
|
|
|
|
|
assert result["task"]["id"] == "task-123"
|
|
|
|
|
mock_create.assert_called_once()
|
|
|
|
|
# Verify task_type is 'handoff'
|
|
|
|
|
call_args = mock_create.call_args
|
|
|
|
|
assert call_args[0][3]["task_type"] == "handoff"
|