feat(M6): Weitere Hosts — MiniApps in Fenstern + AI-Agenten-Ausgabe-Bloecke (#364)
- Windows-Host: openMiniAppWindow-Helper + MiniAppWindowContent (windowStore); Oeffnen-Buttons im Chat-Block (MiniAppBlock) und Dashboard-Widget - AI-Agenten-Host: Core-Tool send_miniapp (app/ai/miniapp_tools.py) — miniapp-Block in Agent-Chat (approval_request-Praezedenz), Permission fail-closed gegen aufrufenden User pro App; Registrierung im lifespan - agent_loop: tool_context + agent_name (Raum-Aufloesung) - Fix: MiniAppBlock nutzt useMiniapps (component-Feld) statt Legacy /comm/miniapps - Tests: M6 7/7 (TDD rot->gruen), Backend-Regression 57/57, Vitest 26/26 (4 neue Window-Tests), tsc clean, build OK
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user