2026-08-31 01:04:33 +02:00
|
|
|
"""M6 — further hosts tests.
|
|
|
|
|
|
|
|
|
|
Windows host: MiniApps open in floating windows (frontend — covered by
|
|
|
|
|
vitest). AI agent host: the core tool ``send_miniapp`` lets agents embed a
|
|
|
|
|
MiniApp as an output block in their chat (approval_request precedent from
|
|
|
|
|
agent_loop), permission-checked fail-closed against the calling user.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.ai.tool_registry import get_tool_registry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _clean_registries():
|
|
|
|
|
from app.plugins.miniapp_registry import reset_miniapp_registry
|
|
|
|
|
|
|
|
|
|
reset_miniapp_registry()
|
|
|
|
|
yield
|
|
|
|
|
reset_miniapp_registry()
|
|
|
|
|
get_tool_registry()._tools.pop("send_miniapp", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ctx(**overrides) -> dict:
|
|
|
|
|
# db is always present in production tool contexts (agent_loop passes
|
|
|
|
|
# the session); a plain object() stands in for tests where the handler
|
|
|
|
|
# only forwards it to mocked collaborators.
|
|
|
|
|
ctx = {
|
|
|
|
|
"db": object(),
|
|
|
|
|
"tenant_id": "00000000-0000-0000-0000-000000000001",
|
|
|
|
|
"user_id": "00000000-0000-0000-0000-000000000002",
|
|
|
|
|
"agent_name": "TestAgent",
|
|
|
|
|
}
|
|
|
|
|
ctx.update(overrides)
|
|
|
|
|
return ctx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSendMiniAppToolRegistration:
|
|
|
|
|
def test_register_registers_tool(self):
|
|
|
|
|
from app.ai.miniapp_tools import register_miniapp_tools
|
|
|
|
|
|
|
|
|
|
register_miniapp_tools()
|
|
|
|
|
tool = get_tool_registry().get("send_miniapp")
|
|
|
|
|
assert tool is not None
|
|
|
|
|
assert tool.plugin_name == "system"
|
|
|
|
|
assert tool.category == "ui"
|
|
|
|
|
# App id is the only required parameter
|
|
|
|
|
props = tool.parameters.get("properties", {})
|
|
|
|
|
assert "app_id" in props
|
|
|
|
|
assert tool.parameters.get("required") == ["app_id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSendMiniAppHandler:
|
|
|
|
|
async def test_unknown_app_returns_error(self):
|
|
|
|
|
from app.ai.miniapp_tools import _send_miniapp_handler
|
|
|
|
|
|
|
|
|
|
result = await _send_miniapp_handler({"app_id": "no_such_app"}, _ctx())
|
|
|
|
|
assert "not found" in result.lower()
|
|
|
|
|
|
|
|
|
|
async def test_unknown_app_never_touches_chat(self, monkeypatch):
|
|
|
|
|
"""Fail-closed: unknown app must not post anything anywhere."""
|
|
|
|
|
from app.ai import miniapp_tools
|
|
|
|
|
|
|
|
|
|
called = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
miniapp_tools, "_get_komm_contract", lambda: (_ for _ in ()).throw(AssertionError("must not be called"))
|
|
|
|
|
)
|
|
|
|
|
from app.ai.miniapp_tools import _send_miniapp_handler
|
|
|
|
|
|
|
|
|
|
await _send_miniapp_handler({"app_id": "nope"}, _ctx())
|
|
|
|
|
assert called == []
|
|
|
|
|
|
|
|
|
|
async def test_permission_denied_returns_error(self, monkeypatch):
|
|
|
|
|
"""User without the app permission cannot make the agent send it."""
|
|
|
|
|
from app.ai import miniapp_tools
|
|
|
|
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
|
|
|
|
|
|
|
|
|
get_miniapp_registry().register(
|
|
|
|
|
app_id="tasks_widget", name="Tasks", plugin_name="tasks",
|
|
|
|
|
permission="tasks:read", component="@/components/x",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def fake_resolve(db, user_id, tenant_id):
|
|
|
|
|
return {"permissions": set(), "denied": set(), "is_system_admin": False}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(miniapp_tools, "resolve_permissions", fake_resolve)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
miniapp_tools, "_get_komm_contract", lambda: (_ for _ in ()).throw(AssertionError("must not post"))
|
|
|
|
|
)
|
|
|
|
|
from app.ai.miniapp_tools import _send_miniapp_handler
|
|
|
|
|
|
|
|
|
|
result = await _send_miniapp_handler({"app_id": "tasks_widget"}, _ctx())
|
|
|
|
|
assert "permission" in result.lower()
|
|
|
|
|
|
|
|
|
|
async def test_posts_miniapp_block_to_agent_room(self, monkeypatch):
|
|
|
|
|
"""Happy path: block with app_id + settings lands in the agent room."""
|
|
|
|
|
from app.ai import miniapp_tools
|
|
|
|
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
|
|
|
|
|
|
|
|
|
get_miniapp_registry().register(
|
|
|
|
|
app_id="open_widget", name="Open", plugin_name="test",
|
|
|
|
|
permission="", component="@/components/x",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def fake_resolve(db, user_id, tenant_id):
|
|
|
|
|
return {"permissions": {"*:*"}, "denied": set(), "is_system_admin": True}
|
|
|
|
|
|
|
|
|
|
posted: list[dict] = []
|
|
|
|
|
|
|
|
|
|
class FakeKomm:
|
|
|
|
|
async def find_locked_room_id(self, db, tenant_id, plugin_name, title):
|
|
|
|
|
assert plugin_name == "automation"
|
|
|
|
|
assert title == "Agent: TestAgent"
|
|
|
|
|
return "conv-123"
|
|
|
|
|
|
|
|
|
|
async def send_message(self, **kwargs):
|
|
|
|
|
posted.append(kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(miniapp_tools, "resolve_permissions", fake_resolve)
|
|
|
|
|
monkeypatch.setattr(miniapp_tools, "_get_komm_contract", lambda: FakeKomm())
|
|
|
|
|
from app.ai.miniapp_tools import _send_miniapp_handler
|
|
|
|
|
|
|
|
|
|
result = await _send_miniapp_handler(
|
|
|
|
|
{"app_id": "open_widget", "settings": {"limit": 5}}, _ctx()
|
|
|
|
|
)
|
|
|
|
|
assert "sent" in result.lower() or "success" in result.lower()
|
|
|
|
|
assert len(posted) == 1
|
|
|
|
|
msg = posted[0]
|
|
|
|
|
assert msg["conversation_id"] == "conv-123"
|
|
|
|
|
assert msg["sender_type"] == "agent"
|
|
|
|
|
blocks = msg["blocks"]
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0]["block_type"] == "miniapp"
|
|
|
|
|
assert blocks[0]["block_data"]["app_id"] == "open_widget"
|
|
|
|
|
assert blocks[0]["block_data"]["config"] == {"limit": 5}
|
|
|
|
|
|
|
|
|
|
async def test_missing_room_degrades_gracefully(self, monkeypatch):
|
|
|
|
|
"""No agent chat room -> informative result, no crash."""
|
|
|
|
|
from app.ai import miniapp_tools
|
|
|
|
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
|
|
|
|
|
|
|
|
|
get_miniapp_registry().register(
|
|
|
|
|
app_id="open_widget", name="Open", plugin_name="test", permission=""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def fake_resolve(db, user_id, tenant_id):
|
|
|
|
|
return {"permissions": set(), "denied": set(), "is_system_admin": True}
|
|
|
|
|
|
|
|
|
|
class FakeKomm:
|
|
|
|
|
async def find_locked_room_id(self, db, tenant_id, plugin_name, title):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(miniapp_tools, "resolve_permissions", fake_resolve)
|
|
|
|
|
monkeypatch.setattr(miniapp_tools, "_get_komm_contract", lambda: FakeKomm())
|
|
|
|
|
from app.ai.miniapp_tools import _send_miniapp_handler
|
|
|
|
|
|
|
|
|
|
result = await _send_miniapp_handler({"app_id": "open_widget"}, _ctx())
|
|
|
|
|
assert "room" in result.lower() or "chat" in result.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAgentLoopContext:
|
|
|
|
|
def test_tool_context_carries_agent_name(self):
|
|
|
|
|
"""agent_loop must pass the agent's name so tools can find the room."""
|
|
|
|
|
from app.ai import agent_loop
|
|
|
|
|
|
|
|
|
|
src = inspect.getsource(agent_loop)
|
|
|
|
|
assert '"agent_name"' in src, "tool_context must include agent_name"
|
2026-08-31 01:07:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agents_tools_route_uses_list_for_api():
|
|
|
|
|
"""M6 verification blocker fixed: /api/v1/agents/tools used to call
|
|
|
|
|
registry.list_tools() (nonexistent — 500 on production, pre-existing).
|
|
|
|
|
The route must use list_for_api() with the correct field mapping.
|
|
|
|
|
"""
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from app.plugins.builtins.automation.agent_routes import list_tools
|
|
|
|
|
|
|
|
|
|
src = inspect.getsource(list_tools)
|
|
|
|
|
assert "list_for_api()" in src, "route must use list_for_api()"
|
|
|
|
|
assert "list_tools()" not in src.replace("list_for_api()", "")
|