From d02e1a14a36cad82b12d84d9ced03a385b127fa7 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:04:59 +0800 Subject: [PATCH] test(agent): cover agent service event bindings --- .../api/service/test_agent_service.py | 309 ++++++++++++++++++ .../api/service/test_bot_service.py | 204 ++++++++++++ .../unit_tests/platform/test_routing_rules.py | 106 ++++++ 3 files changed, 619 insertions(+) create mode 100644 tests/unit_tests/api/service/test_agent_service.py diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py new file mode 100644 index 000000000..c0e63c95e --- /dev/null +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.http.service.agent import ( + AGENT_DEFAULT_EVENT_PATTERNS, + AGENT_KIND_AGENT, + AGENT_KIND_PIPELINE, + PIPELINE_EVENT_PATTERNS, + AgentService, +) + + +pytestmark = pytest.mark.asyncio + + +def _result(items: list | None = None, first_item=None): + result = Mock() + result.all = Mock(return_value=items or []) + result.first = Mock(return_value=first_item) + return result + + +def _agent_row( + agent_uuid: str = 'agent-1', + name: str = 'Test Agent', + updated_at: dt.datetime | str | None = None, + config: dict | None = None, + supported_event_patterns: list[str] | None = None, +): + return SimpleNamespace( + uuid=agent_uuid, + name=name, + description='Agent description', + emoji='A', + kind=AGENT_KIND_AGENT, + component_ref='plugin:test/runner/default', + config=config or { + 'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0}, + 'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}}, + }, + enabled=True, + supported_event_patterns=supported_event_patterns or ['*'], + created_at=dt.datetime(2026, 1, 1, 9, 0, 0), + updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0), + ) + + +def _serialize_agent(model_cls, entity, masked_columns=None): + return { + 'uuid': entity.uuid, + 'name': entity.name, + 'description': entity.description, + 'emoji': entity.emoji, + 'kind': entity.kind, + 'component_ref': entity.component_ref, + 'config': entity.config, + 'enabled': entity.enabled, + 'supported_event_patterns': entity.supported_event_patterns, + 'created_at': entity.created_at, + 'updated_at': entity.updated_at, + } + + +def _compiled_params(statement): + return statement.compile().params + + +def _compiled_update_values(statement): + return { + key: value + for key, value in statement.compile().params.items() + if not key.startswith('uuid_') + } + + +def _make_app(): + app = SimpleNamespace() + app.persistence_mgr = SimpleNamespace( + execute_async=AsyncMock(), + serialize_model=Mock(side_effect=_serialize_agent), + ) + app.pipeline_service = SimpleNamespace( + get_pipeline_metadata=AsyncMock(return_value=[]), + get_pipelines=AsyncMock(return_value=[]), + get_pipeline=AsyncMock(return_value=None), + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + delete_pipeline=AsyncMock(), + _get_default_values_from_schema=Mock(return_value={}), + ) + app.agent_runner_registry = None + app.logger = Mock() + return app + + +class TestAgentServiceMetadata: + async def test_get_agent_metadata_exposes_runner_config_and_kind_capabilities(self): + app = _make_app() + ai_metadata = {'name': 'ai', 'stages': [{'name': 'runner'}]} + app.pipeline_service.get_pipeline_metadata = AsyncMock( + return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}] + ) + + metadata = await AgentService(app).get_agent_metadata() + + assert metadata['runner_config'] == ai_metadata + assert metadata['kinds'] == [ + { + 'name': AGENT_KIND_AGENT, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + }, + { + 'name': AGENT_KIND_PIPELINE, + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + }, + ] + + +class TestAgentServiceListAndLookup: + async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + return_value=_result( + items=[ + _agent_row( + agent_uuid='agent-1', + updated_at=dt.datetime(2026, 1, 1, 10, 0, 0), + supported_event_patterns=['platform.member.*'], + ) + ] + ) + ) + app.pipeline_service.get_pipelines = AsyncMock( + return_value=[ + { + 'uuid': 'pipeline-1', + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {'ai': {'runner': {'id': 'pipeline-runner'}}}, + 'created_at': '2026-01-01T08:00:00', + 'updated_at': '2026-01-01T11:00:00', + } + ] + ) + + agents = await AgentService(app).get_agents(sort_by='updated_at', sort_order='DESC') + + assert [agent['uuid'] for agent in agents] == ['pipeline-1', 'agent-1'] + assert agents[0]['kind'] == AGENT_KIND_PIPELINE + assert agents[0]['component_ref'] == 'pipeline' + assert agents[0]['capability'] == { + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + } + assert agents[1]['kind'] == AGENT_KIND_AGENT + assert agents[1]['capability'] == { + 'supported_event_patterns': ['platform.member.*'], + 'message_only': False, + } + assert all('config' not in agent for agent in agents) + + async def test_get_agent_returns_agent_with_config_before_pipeline_fallback(self): + app = _make_app() + agent = _agent_row(agent_uuid='agent-1') + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=agent)) + + result = await AgentService(app).get_agent('agent-1') + + assert result['uuid'] == 'agent-1' + assert result['kind'] == AGENT_KIND_AGENT + assert result['config'] == agent.config + app.pipeline_service.get_pipeline.assert_not_awaited() + + async def test_get_agent_falls_back_to_pipeline_product_item_with_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None)) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ + 'uuid': 'pipeline-1', + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {'ai': {'runner': {'id': 'pipeline-runner'}}}, + 'created_at': '2026-01-01T08:00:00', + 'updated_at': '2026-01-01T11:00:00', + } + ) + + result = await AgentService(app).get_agent('pipeline-1') + + assert result['kind'] == AGENT_KIND_PIPELINE + assert result['enabled'] is True + assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}} + assert result['capability']['message_only'] is True + + +class TestAgentServiceCreateUpdateDelete: + async def test_create_agent_uses_default_runner_config_from_registry(self): + app = _make_app() + runner = SimpleNamespace( + id='plugin:langbot/local-agent/default', + config_schema=[ + {'name': 'model', 'default': 'gpt-4.1'}, + {'name': 'temperature', 'default': 0.2}, + {'name': 'no-default'}, + ], + ) + app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner])) + app.pipeline_service._get_default_values_from_schema = Mock( + return_value={'model': 'gpt-4.1', 'temperature': 0.2} + ) + app.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) + + result = await AgentService(app).create_agent( + { + 'name': 'Support Agent', + 'description': 'Handles support events', + 'emoji': 'S', + } + ) + + insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + assert result['kind'] == AGENT_KIND_AGENT + assert result['uuid'] == insert_values['uuid'] + assert insert_values['name'] == 'Support Agent' + assert insert_values['component_ref'] == runner.id + assert insert_values['config'] == { + 'runner': {'id': runner.id, 'expire-time': 0}, + 'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}}, + } + assert insert_values['enabled'] is True + assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS + app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema) + + async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + new_config = { + 'runner': {'id': 'plugin:test/new-runner/default', 'expire-time': 0}, + 'runner_config': {'plugin:test/new-runner/default': {'timeout': 30}}, + } + + await AgentService(app).update_agent( + 'agent-1', + { + 'uuid': 'caller-owned-uuid', + 'kind': AGENT_KIND_PIPELINE, + 'created_at': '2020-01-01T00:00:00', + 'updated_at': '2020-01-01T00:00:00', + 'capability': {'message_only': True}, + 'name': 'Updated Agent', + 'config': new_config, + 'supported_event_patterns': [], + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == { + 'name': 'Updated Agent', + 'config': new_config, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'component_ref': 'plugin:test/new-runner/default', + } + + async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None)) + app.pipeline_service.create_pipeline = AsyncMock(return_value='pipeline-created') + app.pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipeline-1'}) + service = AgentService(app) + + created = await service.create_agent( + { + 'kind': AGENT_KIND_PIPELINE, + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + } + ) + await service.update_agent('pipeline-1', {'name': 'Updated Pipeline'}) + await service.delete_agent('pipeline-1') + + assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE} + app.pipeline_service.create_pipeline.assert_awaited_once_with( + { + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {}, + } + ) + app.pipeline_service.update_pipeline.assert_awaited_once_with( + 'pipeline-1', + {'name': 'Updated Pipeline'}, + ) + app.pipeline_service.delete_pipeline.assert_awaited_once_with('pipeline-1') diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index cc3968abe..3fe5fb336 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -52,6 +52,15 @@ def _create_mock_result(items: list = None, first_item=None): return result +def _compiled_update_values(statement): + """Return update values without SQLAlchemy WHERE bind params.""" + return { + key: value + for key, value in statement.compile().params.items() + if not key.startswith('uuid_') + } + + def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None): """Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests. @@ -531,6 +540,201 @@ class TestBotServiceUpdateBot: assert update_params['use_pipeline_name'] == 'Updated Pipeline' +class TestBotServiceEventBindings: + """Tests for EBA event binding validation and persistence.""" + + async def test_normalize_event_bindings_validates_targets_and_preserves_order(self): + """Valid bindings are normalized with stable order and target data.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')), + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + supported_event_patterns=['platform.member.*'], + ) + ), + ] + ) + service = BotService(ap) + + normalized = await service._normalize_event_bindings( + [ + { + 'event_pattern': ' message.received ', + 'target_type': 'pipeline', + 'target_uuid': ' pipeline-1 ', + 'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}], + 'priority': '5', + 'enabled': False, + 'description': 'Route message events', + }, + { + 'id': 'agent-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 7, + }, + { + 'event_pattern': 'platform.member.left', + 'target_type': 'discard', + 'target_uuid': 'ignored-target', + }, + ] + ) + + uuid.UUID(normalized[0]['id']) + assert normalized == [ + { + 'id': normalized[0]['id'], + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}], + 'priority': 5, + 'enabled': False, + 'description': 'Route message events', + 'order': 0, + }, + { + 'id': 'agent-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [], + 'priority': 7, + 'enabled': True, + 'description': '', + 'order': 1, + }, + { + 'id': normalized[2]['id'], + 'event_pattern': 'platform.member.left', + 'target_type': 'discard', + 'target_uuid': '', + 'filters': [], + 'priority': 0, + 'enabled': True, + 'description': '', + 'order': 2, + }, + ] + + async def test_normalize_event_bindings_rejects_pipeline_for_non_message_event(self): + """Pipeline targets are limited to message events.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) + service = BotService(ap) + + with pytest.raises(ValueError, match='Pipeline can only be bound to message events'): + await service._normalize_event_bindings( + [ + { + 'event_pattern': 'platform.member.joined', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + } + ] + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + ('agent', 'error'), + [ + (None, 'Agent not found'), + ( + SimpleNamespace(uuid='agent-1', supported_event_patterns=['message.*']), + 'Agent does not support this event pattern', + ), + ], + ) + async def test_normalize_event_bindings_rejects_invalid_agent_target(self, agent, error): + """Agent targets must exist and support the requested event pattern.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace( + execute_async=AsyncMock(return_value=_create_mock_result(first_item=agent)) + ) + service = BotService(ap) + + with pytest.raises(ValueError, match=error): + await service._normalize_event_bindings( + [ + { + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ] + ) + + async def test_update_bot_persists_normalized_event_bindings_and_reloads_runtime_bot(self): + """update_bot stores normalized bindings before reloading the runtime bot.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + supported_event_patterns=['platform.member.*'], + ) + ), + Mock(), + ] + ) + runtime_bot = SimpleNamespace(enable=True, run=AsyncMock()) + loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'} + ap.platform_mgr = SimpleNamespace( + remove_bot=AsyncMock(), + load_bot=AsyncMock(return_value=runtime_bot), + ) + bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1')) + other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot')) + ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session]) + service = BotService(ap) + service.get_bot = AsyncMock(return_value=loaded_bot) + + await service.update_bot( + 'bot-1', + { + 'event_bindings': [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': '9', + } + ] + }, + ) + + update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values['event_bindings'] == [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [], + 'priority': 9, + 'enabled': True, + 'description': '', + 'order': 0, + } + ] + ap.platform_mgr.remove_bot.assert_awaited_once_with('bot-1') + service.get_bot.assert_awaited_once_with('bot-1') + ap.platform_mgr.load_bot.assert_awaited_once_with(loaded_bot) + runtime_bot.run.assert_awaited_once() + assert bot_session.using_conversation is None + assert other_session.using_conversation.bot_uuid == 'other-bot' + + class TestBotServiceDeleteBot: """Tests for delete_bot method.""" diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 3928f6f11..cff005914 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -2,6 +2,7 @@ RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests """ +from types import SimpleNamespace from unittest.mock import Mock @@ -278,3 +279,108 @@ class TestResolvePipelineUuid: uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message') assert uuid == 'default-uuid' assert routed is False + + +class TestEBAEventBindings: + """Test RuntimeBot EBA event binding helpers.""" + + @staticmethod + def _make_bot(bindings): + from langbot.pkg.platform.botmgr import RuntimeBot + + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(event_bindings=bindings) + return bot + + def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self): + """The selected binding is the first matching highest-priority binding.""" + bot = self._make_bot( + [ + { + 'id': 'disabled', + 'enabled': False, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-disabled', + 'priority': 100, + 'order': 0, + }, + { + 'id': 'wrong-room', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-wrong-room', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-2'}], + 'priority': 50, + 'order': 1, + }, + { + 'id': 'first-high', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-first', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 10, + 'order': 2, + }, + { + 'id': 'second-high', + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-second', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 10, + 'order': 3, + }, + { + 'id': 'fallback', + 'event_pattern': '*', + 'target_type': 'discard', + 'priority': 1, + 'order': 4, + }, + ] + ) + + selected = bot._resolve_eba_event_binding( + {'room': {'id': 'room-1'}}, + 'platform.member.joined', + ) + + assert selected['id'] == 'first-high' + assert selected['target_uuid'] == 'agent-first' + + def test_agent_product_to_binding_projects_runner_config_and_policies(self): + """Agent products become bot-scoped runner bindings for EBA dispatch.""" + from langbot.pkg.platform.botmgr import RuntimeBot + + binding = RuntimeBot._agent_product_to_binding( + { + 'uuid': 'agent-1', + 'component_ref': 'plugin:test/fallback/default', + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'temperature': 0.2, + 'max_tokens': 1000, + } + }, + }, + }, + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) + + assert binding is not None + assert binding.binding_id == 'bot:bot-1:binding-1' + assert binding.scope.scope_type == 'bot' + assert binding.scope.scope_id == 'bot-1' + assert binding.event_types == ['platform.member.joined'] + assert binding.runner_id == 'plugin:test/runner/default' + assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000} + assert binding.delivery_policy.enable_streaming is False + assert binding.delivery_policy.enable_reply is True + assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner'] + assert binding.agent_id == 'agent-1'