mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
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]>
201 lines
7.4 KiB
Python
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'}
|