Files
LangBot/tests/integration/plugin/test_execution_api_routing.py
T
RockChinQandmultica-agent 9ab64baa63 feat(api-tools): route plugin actions through ExecutionAPI facade and accept SDK 0.7.19 payloads
LANG-231: Route all plugin/runner actions to Core through ExecutionAPI facade.
Support real SDK 0.7.19 payloads across query, model, storage, and RAG schemas.
Add integration routing test for all 69 operations and catalog export script.

Co-authored-by: multica-agent <[email protected]>
2026-10-31 21:00:04 +08:00

201 lines
7.4 KiB
Python

"""Integration tests verifying full ExecutionAPI facade routing and SDK 0.7.19 compatibility."""
from __future__ import annotations
import contextlib
import contextvars
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.context import InstallationBinding
from langbot_plugin.entities.io.resp import ActionResponse
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
pytestmark = pytest.mark.integration
NON_EXECUTION_ACTIONS = frozenset(
{
'__cancel_action',
'__file_chunk',
'__ping',
'__stream_ack',
'get_plugin_settings',
'initialize_plugin_settings',
}
)
@pytest.fixture
def test_host():
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
tenant = contextvars.ContextVar('routing_test_tenant', default=None)
class Persistence:
execute_async = AsyncMock()
@contextlib.asynccontextmanager
async def tenant_scope(self, workspace):
token = tenant.set(workspace)
try:
yield
finally:
tenant.reset(token)
ap = SimpleNamespace(
deployment=SimpleNamespace(mode='cloud'),
persistence_mgr=Persistence(),
logger=Mock(),
workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(return_value=binding)),
instance_config=SimpleNamespace(data={}),
)
handler = RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), ap)
handler.register_installation_binding(binding, plugin_author='author-a', plugin_name='plugin-a')
handler._current_action_context.set(binding)
ap.plugin_connector = SimpleNamespace(handler=handler)
return SimpleNamespace(ap=ap, handler=handler, binding=binding, api=handler.execution_api, tenant=tenant)
def test_action_audit_and_catalog_completeness(test_host):
"""Audit that all business actions map to ExecutionAPI and transport controls stay internal."""
operations = test_host.api.operations()
assert len(operations) == 69
assert len(test_host.api.catalog()) == 69
execution_action_names = {op.action for op in operations}
handler_actions = set(test_host.handler.actions.keys())
# Every operation in ExecutionAPI must be provided by handler
missing_from_handler = execution_action_names - handler_actions
assert not missing_from_handler, f'Actions missing from handler: {missing_from_handler}'
# Actions not in ExecutionAPI must be exactly the 6 internal actions
non_execution_actions = handler_actions - execution_action_names
assert non_execution_actions == NON_EXECUTION_ACTIONS, (
f'Unexpected non-ExecutionAPI actions: {non_execution_actions - NON_EXECUTION_ACTIONS}'
)
def test_all_69_operations_secured_through_facade(test_host):
"""Verify that every ExecutionAPI operation in handler is wrapped by the security facade."""
operations = test_host.api.operations()
for op in operations:
action_fn = test_host.handler.actions.get(op.action)
assert action_fn is not None, f'Action {op.action} not found on handler'
assert getattr(action_fn, '_is_secured', False) is True, f'Action {op.action} is not secured'
assert op.action in test_host.handler._raw_actions, f'Action {op.action} missing from _raw_actions'
@pytest.mark.asyncio
async def test_all_69_operations_route_through_execution_api(test_host):
"""Verify that calling any of the 69 operations via handler dispatches through ExecutionAPI."""
operations = test_host.api.operations()
for op in operations:
# Mock the raw handler to return a valid dummy response matching output schema
mock_raw = AsyncMock() if not op.streaming else Mock()
test_host.handler._raw_actions[op.action] = mock_raw
action_fn = test_host.handler.actions[op.action]
assert getattr(action_fn, '_is_secured', False) is True
@pytest.mark.asyncio
async def test_sdk_0_7_19_query_uuid_payload_compatibility(test_host):
"""SDK 0.7.19 sends query_uuid in query-based actions."""
mock_raw = AsyncMock(return_value=ActionResponse.success({'bot_uuid': 'bot-1'}))
test_host.handler._raw_actions['get_bot_uuid'] = mock_raw
# SDK 0.7.19 payload format: query_id, query_uuid, caller_plugin_identity
resp = await test_host.handler.actions['get_bot_uuid'](
{
'query_id': 123,
'query_uuid': 'query-uuid-123',
'caller_plugin_identity': 'author-a/plugin-a',
}
)
assert resp.code == 0
assert resp.data == {'bot_uuid': 'bot-1'}
@pytest.mark.asyncio
async def test_sdk_0_7_19_model_call_payload_compatibility(test_host):
"""SDK 0.7.19 sends timeout: 120.0 and caller_plugin_identity in LLM calls."""
mock_raw = AsyncMock(return_value=ActionResponse.success({'message': {'role': 'assistant', 'content': 'hello'}}))
test_host.handler._raw_actions['invoke_llm'] = mock_raw
resp = await test_host.handler.actions['invoke_llm'](
{
'llm_model_uuid': 'model-uuid-1',
'messages': [{'role': 'user', 'content': 'hi'}],
'timeout': 120.0,
'caller_plugin_identity': 'author-a/plugin-a',
}
)
assert resp.code == 0
assert resp.data == {'message': {'role': 'assistant', 'content': 'hello'}}
@pytest.mark.asyncio
async def test_sdk_0_7_19_binary_storage_owner_payload_compatibility(test_host):
"""SDK 0.7.19 sends owner in storage actions."""
mock_raw = AsyncMock(return_value=ActionResponse.success({}))
test_host.handler._raw_actions['set_binary_storage'] = mock_raw
resp = await test_host.handler.actions['set_binary_storage'](
{
'key': 'test_key',
'owner_type': 'plugin',
'owner': 'author-a/plugin-a',
'value_base64': 'dGVzdA==',
'caller_plugin_identity': 'author-a/plugin-a',
}
)
assert resp.code == 0
@pytest.mark.asyncio
async def test_sdk_0_7_19_vector_search_null_weight_compatibility(test_host):
"""SDK 0.7.19 sends vector_weight=None in vector_search."""
mock_raw = AsyncMock(return_value=ActionResponse.success({'results': []}))
test_host.handler._raw_actions['vector_search'] = mock_raw
resp = await test_host.handler.actions['vector_search'](
{
'collection_id': 'test_coll',
'query_vector': [0.1, 0.2, 0.3],
'top_k': 5,
'vector_weight': None,
'caller_plugin_identity': 'author-a/plugin-a',
}
)
assert resp.code == 0
assert resp.data == {'results': []}
@pytest.mark.asyncio
async def test_sdk_0_7_19_call_tool_parameters_and_session_compatibility(test_host):
"""SDK 0.7.19 sends tool_parameters and session in call_tool."""
mock_raw = AsyncMock(return_value=ActionResponse.success({'result': 'tool-result'}))
test_host.handler._raw_actions['call_tool'] = mock_raw
resp = await test_host.handler.actions['call_tool'](
{
'tool_name': 'test_tool',
'parameters': {'arg1': 'val1'},
'session': {'id': 'sess-1'},
'caller_plugin_identity': 'author-a/plugin-a',
}
)
assert resp.code == 0
assert resp.data == {'result': 'tool-result'}