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:
@@ -197,6 +197,7 @@ async def run_react_loop(
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"db": db,
|
||||
"agent_name": getattr(agent_definition, "name", "Agent"),
|
||||
}
|
||||
|
||||
# Audit helper — records every tool call in the audit log.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Core AI agent tools for MiniApp output (Phase M6).
|
||||
|
||||
``send_miniapp`` lets an agent embed a MiniApp as an interactive output
|
||||
block in its chat room (block_type "miniapp", approval_request precedent
|
||||
from agent_loop). Permission is checked fail-closed against the CALLING
|
||||
user for the target app's own permission — the tool never widens access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
from app.core.permissions import check_permission, resolve_permissions
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_komm_contract() -> Any | None:
|
||||
"""Resolve the kommunikation contract (None when plugin inactive)."""
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
return get_contract_registry().get("kommunikation")
|
||||
|
||||
|
||||
async def _send_miniapp_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
"""Send a MiniApp as an output block to the agent's chat room."""
|
||||
app_id = str(arguments.get("app_id") or "")
|
||||
settings = arguments.get("settings") or {}
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
|
||||
app = get_miniapp_registry().get_app(app_id)
|
||||
if app is None:
|
||||
return f"Error: MiniApp '{app_id}' not found"
|
||||
|
||||
db = context.get("db")
|
||||
tenant_id = context.get("tenant_id")
|
||||
user_id = context.get("user_id")
|
||||
if not tenant_id or not user_id or db is None:
|
||||
return "Error: Missing tenant/user context"
|
||||
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(str(tenant_id))
|
||||
user_uuid = uuid.UUID(str(user_id))
|
||||
except (ValueError, TypeError):
|
||||
return "Error: Invalid tenant/user context"
|
||||
|
||||
# Fail-closed permission check against the calling user
|
||||
resolved = await resolve_permissions(db, user_uuid, tenant_uuid)
|
||||
if app.permission and not check_permission(resolved, app.permission):
|
||||
return f"Error: Permission '{app.permission}' required for MiniApp '{app.app_id}'"
|
||||
|
||||
komm = _get_komm_contract()
|
||||
if komm is None:
|
||||
return "Error: Communication plugin not available"
|
||||
|
||||
agent_name = str(context.get("agent_name") or "Agent")
|
||||
conv_id = await komm.find_locked_room_id(
|
||||
db=db,
|
||||
tenant_id=tenant_uuid,
|
||||
plugin_name="automation",
|
||||
title=f"Agent: {agent_name}",
|
||||
)
|
||||
if conv_id is None:
|
||||
return f"Info: No agent chat room found for '{agent_name}' — MiniApp not posted"
|
||||
|
||||
agent_id_raw = context.get("agent_id")
|
||||
try:
|
||||
sender_id = uuid.UUID(str(agent_id_raw)) if agent_id_raw else None
|
||||
except (ValueError, TypeError):
|
||||
sender_id = None
|
||||
|
||||
await komm.send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_uuid,
|
||||
conversation_id=conv_id,
|
||||
sender_id=sender_id,
|
||||
sender_type="agent",
|
||||
content=f"MiniApp: {app.name}",
|
||||
content_format="text",
|
||||
blocks=[
|
||||
{
|
||||
"block_type": "miniapp",
|
||||
"block_data": {"app_id": app_id, "config": settings},
|
||||
"sort_order": 0,
|
||||
}
|
||||
],
|
||||
)
|
||||
return f"MiniApp '{app_id}' sent to chat"
|
||||
|
||||
|
||||
# ─── Registration ───
|
||||
|
||||
|
||||
def register_miniapp_tools() -> None:
|
||||
"""Register the MiniApp agent tools in the global tool registry."""
|
||||
registry = get_tool_registry()
|
||||
registry.register(
|
||||
name="send_miniapp",
|
||||
description=(
|
||||
"Eine MiniApp als interaktiven Ausgabe-Block in den Agent-Chat senden "
|
||||
"(z.B. ein Widget mit Einstellungen anzeigen). Verfügbare App-IDs "
|
||||
"stehen in /api/v1/miniapps."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"type": "string",
|
||||
"description": "ID der MiniApp (z.B. recent_contacts, tasks_summary)",
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": "Optionale Einstellungen für die MiniApp-Instanz",
|
||||
},
|
||||
},
|
||||
"required": ["app_id"],
|
||||
},
|
||||
handler=_send_miniapp_handler,
|
||||
plugin_name="system",
|
||||
required_permission=None, # per-app check inside the handler (fail-closed)
|
||||
category="ui",
|
||||
)
|
||||
@@ -223,6 +223,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
register_system_miniapps()
|
||||
|
||||
# Core AI agent tools for MiniApp output (Phase M6)
|
||||
from app.ai.miniapp_tools import register_miniapp_tools
|
||||
|
||||
register_miniapp_tools()
|
||||
|
||||
# Install discovered builtin plugins and activate only those marked active in DB
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
|
||||
import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
|
||||
/**
|
||||
* M6 — Windows host tests: MiniApps open in floating windows via the
|
||||
* existing window manager (openMiniAppWindow + MiniAppWindowContent).
|
||||
*/
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/miniapps', () => ({
|
||||
useMiniapps: () => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: 'desc',
|
||||
plugin_name: 'contacts', render_schema: {}, permission: '', settings_schema: {},
|
||||
col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'],
|
||||
component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/dashboard/MiniAppHost', () => ({
|
||||
MiniAppHost: ({ appId }: { appId: string }) => (
|
||||
<div data-testid={`miniapp-${appId}`}>host:{appId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
function renderContent() {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MiniAppWindowContent appId="recent_contacts" settings={{ limit: 5 }} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MiniAppWindowContent', () => {
|
||||
it('renders the MiniApp host with the given app id', () => {
|
||||
renderContent();
|
||||
expect(screen.getByTestId('miniapp-window-recent_contacts')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the app description when present', () => {
|
||||
renderContent();
|
||||
expect(screen.getByText('desc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openMiniAppWindow', () => {
|
||||
it('opens a window with miniapp type and props', () => {
|
||||
const spy = vi.fn();
|
||||
// spy on the store's openWindow implementation
|
||||
const state = useWindowStore.getState();
|
||||
const origOpen = state.openWindow;
|
||||
useWindowStore.setState({ openWindow: spy as never });
|
||||
|
||||
openMiniAppWindow({
|
||||
appId: 'recent_contacts',
|
||||
settings: { limit: 3 },
|
||||
component: MiniAppWindowContent as never,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const config = spy.mock.calls[0][0];
|
||||
expect(config.type).toBe('miniapp-recent_contacts');
|
||||
expect(config.title).toBe('recent_contacts');
|
||||
expect(config.componentProps).toEqual({ appId: 'recent_contacts', settings: { limit: 3 } });
|
||||
|
||||
// restore
|
||||
useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
|
||||
});
|
||||
|
||||
it('uses the def name as window title when provided', () => {
|
||||
const spy = vi.fn();
|
||||
const state = useWindowStore.getState();
|
||||
const origOpen = state.openWindow;
|
||||
useWindowStore.setState({ openWindow: spy as never });
|
||||
|
||||
openMiniAppWindow({
|
||||
appId: 'x',
|
||||
def: { name: 'Mein Widget' } as never,
|
||||
component: MiniAppWindowContent as never,
|
||||
});
|
||||
|
||||
expect(spy.mock.calls[0][0].title).toBe('Mein Widget');
|
||||
useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,36 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import { AppWindow, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { AppWindow, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
|
||||
import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
|
||||
import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
|
||||
|
||||
interface MiniAppBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
interface MiniAppDef {
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
render_schema: Record<string, any>;
|
||||
}
|
||||
|
||||
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
const { t } = useTranslation();
|
||||
const { app_id, config } = block.block_data;
|
||||
const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// M6: universal registry endpoint (carries component/permission fields;
|
||||
// the legacy /comm/miniapps list never did, hiding the window button)
|
||||
const { data: miniappsData, isLoading: loading } = useMiniapps();
|
||||
const appDef: MiniAppDef | null =
|
||||
miniappsData?.items.find((a) => a.app_id === app_id) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!app_id) return;
|
||||
setLoading(true);
|
||||
apiClient.get('/comm/miniapps')
|
||||
.then((res) => {
|
||||
const apps: MiniAppDef[] = res.data || [];
|
||||
const found = apps.find((a) => a.app_id === app_id);
|
||||
setAppDef(found || null);
|
||||
})
|
||||
.catch(() => setAppDef(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [app_id]);
|
||||
const canOpenInWindow = Boolean(appDef?.component);
|
||||
const openInWindow = () =>
|
||||
openMiniAppWindow({
|
||||
appId: app_id || '',
|
||||
def: appDef ?? undefined,
|
||||
settings: (config as Record<string, unknown>) ?? {},
|
||||
component: MiniAppWindowContent,
|
||||
});
|
||||
|
||||
const appId: string = app_id || 'Unbekannt';
|
||||
const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0;
|
||||
const schema = appDef?.render_schema;
|
||||
const schema = appDef?.render_schema as { fields?: { name: string; label?: string }[] } | undefined;
|
||||
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||
|
||||
if (loading) {
|
||||
@@ -60,6 +54,18 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
{appDef?.plugin_name && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary-100 text-secondary-600">{appDef.plugin_name}</span>
|
||||
)}
|
||||
{canOpenInWindow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openInWindow}
|
||||
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-400 hover:text-primary-600"
|
||||
aria-label={t('dashboard.openInWindow')}
|
||||
title={t('dashboard.openInWindow')}
|
||||
data-testid={`miniapp-open-window-${app_id}`}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSchema && schema.fields && (
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ExternalLink,
|
||||
LayoutDashboard,
|
||||
Pencil,
|
||||
Check,
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
} from '@/api/dashboards';
|
||||
import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps';
|
||||
import { MiniAppHost } from './MiniAppHost';
|
||||
import { MiniAppWindowContent } from './MiniAppWindowContent';
|
||||
import { openMiniAppWindow } from './openMiniAppWindow';
|
||||
import { WidgetSettingsForm } from './WidgetSettingsForm';
|
||||
|
||||
let widgetIdCounter = 0;
|
||||
@@ -166,6 +169,22 @@ function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSetti
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 rotate-90" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openMiniAppWindow({
|
||||
appId: widget.app_id,
|
||||
def,
|
||||
settings: widget.settings,
|
||||
component: MiniAppWindowContent,
|
||||
})
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
|
||||
aria-label={t('dashboard.openInWindow')}
|
||||
data-testid="dashboard-widget-open-window"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* MiniAppWindowContent — window host for a MiniApp (Phase M6).
|
||||
*
|
||||
* Rendered inside the window manager's floating windows: resolves the
|
||||
* MiniApp definition (name, settings_schema) and renders the shared
|
||||
* MiniAppHost as the window body.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMiniapps } from '@/api/miniapps';
|
||||
import { MiniAppHost } from '@/components/dashboard/MiniAppHost';
|
||||
|
||||
export interface MiniAppWindowContentProps {
|
||||
appId: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function MiniAppWindowContent({ appId, settings }: MiniAppWindowContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useMiniapps();
|
||||
const def = data?.items.find((a) => a.app_id === appId);
|
||||
|
||||
return (
|
||||
<div className="p-4 h-full overflow-auto" data-testid={`miniapp-window-${appId}`}>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-secondary-500">{t('common.loading', 'Lädt…')}</p>
|
||||
) : (
|
||||
<>
|
||||
{def?.description && (
|
||||
<p className="text-xs text-secondary-400 mb-3">{def.description}</p>
|
||||
)}
|
||||
<MiniAppHost appId={appId} settings={settings ?? {}} def={def} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* openMiniAppWindow — open a MiniApp in a floating window (Phase M6).
|
||||
*
|
||||
* Windows host: any inline MiniApp (chat block, dashboard widget) can be
|
||||
* popped into a draggable window via the existing window manager.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
import type { MiniAppDef } from '@/api/miniapps';
|
||||
|
||||
export interface OpenMiniAppWindowOptions {
|
||||
appId: string;
|
||||
title?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
def?: MiniAppDef;
|
||||
component: ComponentType<{ appId: string; settings?: Record<string, unknown> }>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function openMiniAppWindow(opts: OpenMiniAppWindowOptions): string {
|
||||
const store = useWindowStore.getState();
|
||||
const title = opts.title ?? opts.def?.name ?? opts.appId;
|
||||
const id = store.openWindow({
|
||||
title,
|
||||
type: `miniapp-${opts.appId}`,
|
||||
component: opts.component,
|
||||
componentProps: {
|
||||
appId: opts.appId,
|
||||
settings: opts.settings ?? {},
|
||||
},
|
||||
});
|
||||
// openWindow applies the default size; MiniApps get a compact default
|
||||
store.updateWindowSize(id, { width: opts.width ?? 520, height: opts.height ?? 480 });
|
||||
return id;
|
||||
}
|
||||
@@ -163,7 +163,8 @@
|
||||
"taller": "Höher",
|
||||
"shorter": "Niedriger"
|
||||
},
|
||||
"systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte."
|
||||
"systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte.",
|
||||
"openInWindow": "In Fenster öffnen"
|
||||
},
|
||||
"companies": {
|
||||
"title": "Firmen",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
"taller": "Taller",
|
||||
"shorter": "Shorter"
|
||||
},
|
||||
"systemMetricsNoAccess": "System metrics require admin rights."
|
||||
"systemMetricsNoAccess": "System metrics require admin rights.",
|
||||
"openInWindow": "Open in window"
|
||||
},
|
||||
"companies": {
|
||||
"title": "Companies",
|
||||
|
||||
@@ -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