"""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" 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()", "")