diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index e3a13b789..14a086c8b 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -38,6 +38,33 @@ class BotsRouterGroup(group.RouterGroup): logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) return self.success(data={'logs': logs, 'total_count': total_count}) + async def _dry_run_event_route(bot_uuid: str) -> str: + json_data = await quart.request.json + if not isinstance(json_data, dict): + return self.http_status(400, -1, 'invalid request body') + + result = await self.ap.bot_service.dry_run_event_route( + bot_uuid=bot_uuid, + event_type=json_data.get('event_type'), + event_data=json_data.get('event_data', json_data.get('payload')), + context=json_data.get('context'), + event_bindings=json_data.get('event_bindings'), + ) + return self.success(data=result) + + self.route( + '//event-routes/dry-run', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + )(_dry_run_event_route) + # Backward-compatible alias for early local clients/tests created before + # the product route naming was settled. + self.route( + '//event_route/dry_run', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + )(_dry_run_event_route) + @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 93237ec12..3c87357d9 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -15,6 +15,12 @@ class BotService: """Bot service""" ap: app.Application + FAILURE_ROUTE_NOT_FOUND = 'route_not_found' + FAILURE_PROCESSOR_DISABLED = 'processor_disabled' + FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found' + FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible' + FAILURE_INVALID_EVENT = 'invalid_event' + BOT_FIELDS = { 'uuid', 'name', @@ -68,6 +74,384 @@ class BotService: patterns = supported_patterns or ['*'] return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns) + @staticmethod + def _format_diagnostic_step(step: dict[str, typing.Any]) -> str: + step_name = step.get('step') or 'diagnostic' + reason = step.get('reason') or step.get('failure_code') or 'No reason provided' + + if step_name == 'evaluate_binding': + route_number = step.get('binding_index') + if not isinstance(route_number, int): + route_number = step.get('order') + route_label = f'Route {int(route_number) + 1}' if isinstance(route_number, int) else 'Route' + event_pattern = step.get('event_pattern') or '*' + if step.get('selected'): + return f'{route_label} ({event_pattern}) selected: {reason}' + if step.get('matched'): + return f'{route_label} ({event_pattern}) matched: {reason}' + return f'{route_label} ({event_pattern}) skipped: {reason}' + + if step_name == 'validate_processor': + target_type = step.get('target_type') or 'processor' + target_uuid = step.get('target_uuid') or '' + suffix = f' {target_uuid}' if target_uuid else '' + return f'Validate {target_type}{suffix}: {reason}' + + return str(reason) + + @classmethod + def _format_diagnostic_steps(cls, diagnostic_details: list[dict[str, typing.Any]] | None) -> list[str]: + return [cls._format_diagnostic_step(step) for step in diagnostic_details or []] + + @staticmethod + def _target_kind(target_type: typing.Any, target_kind: str | None = None) -> str | None: + if target_kind: + return target_kind + if target_type == 'discard': + return 'discard' + if target_type in {'agent', 'pipeline'}: + return str(target_type) + return None + + @classmethod + def _diagnostic_result( + cls, + *, + matched: bool, + failure_code: str | None = None, + reason: str = '', + binding: dict[str, typing.Any] | None = None, + diagnostic_steps: list[dict[str, typing.Any]] | None = None, + target_name: str | None = None, + target_kind: str | None = None, + ) -> dict[str, typing.Any]: + binding = binding or {} + target_type = binding.get('target_type') + target_uuid = binding.get('target_uuid') or '' + matched_binding_index = binding.get('_dry_run_index') + if not isinstance(matched_binding_index, int): + matched_binding_index = binding.get('order') + if not isinstance(matched_binding_index, int): + matched_binding_index = None + target = None + if target_type: + target = { + 'target_type': target_type, + 'target_uuid': target_uuid or None, + 'target_name': target_name, + 'kind': cls._target_kind(target_type, target_kind), + } + return { + 'matched': matched, + 'binding_id': binding.get('id'), + 'matched_binding_id': binding.get('id'), + 'matched_binding_index': matched_binding_index, + 'event_pattern': binding.get('event_pattern'), + 'target_type': binding.get('target_type'), + 'target_uuid': target_uuid, + 'target': target, + 'reason': reason, + 'failure_code': failure_code, + 'diagnostic_steps': cls._format_diagnostic_steps(diagnostic_steps), + 'diagnostic_details': diagnostic_steps or [], + } + + @staticmethod + def _build_dry_run_event(event_type: str, event_data: typing.Any, context: typing.Any) -> dict[str, typing.Any]: + event: dict[str, typing.Any] = {} + if isinstance(event_data, dict): + event.update(event_data) + elif event_data is not None: + raise ValueError('event_data must be an object') + event['type'] = event_type + + if context is None: + return event + if not isinstance(context, dict): + raise ValueError('context must be an object') + event['context'] = context + return event + + @staticmethod + def _normalize_dry_run_bindings(bindings: typing.Any) -> list[dict[str, typing.Any]]: + if bindings is None: + return [] + if not isinstance(bindings, list): + raise ValueError('event_bindings must be an array') + + normalized: list[dict[str, typing.Any]] = [] + for index, raw_binding in enumerate(bindings): + if not isinstance(raw_binding, dict): + continue + event_pattern = str(raw_binding.get('event_pattern') or '').strip() + target_type = str(raw_binding.get('target_type') or '').strip() + if not event_pattern or not target_type: + continue + + try: + priority = int(raw_binding.get('priority') or 0) + except (TypeError, ValueError): + priority = 0 + + target_uuid = str(raw_binding.get('target_uuid') or '').strip() + if target_type == 'discard': + target_uuid = '' + + filters = raw_binding.get('filters') if isinstance(raw_binding.get('filters'), list) else [] + normalized.append( + { + 'id': raw_binding.get('id'), + 'event_pattern': event_pattern, + 'target_type': target_type, + 'target_uuid': target_uuid, + 'filters': filters, + 'priority': priority, + 'enabled': bool(raw_binding.get('enabled', True)), + # For draft bindings, current array order is the effective order + # that would be persisted on save. + 'order': index, + '_dry_run_index': index, + } + ) + return normalized + + @staticmethod + def _index_event_bindings(bindings: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]: + indexed: list[dict[str, typing.Any]] = [] + for index, binding in enumerate(bindings): + copied = binding.copy() + copied.setdefault('_dry_run_index', index) + indexed.append(copied) + return indexed + + async def _get_pipeline_entity(self, pipeline_uuid: str) -> persistence_pipeline.LegacyPipeline | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + return result.first() + + async def _get_agent_entity(self, agent_uuid: str) -> persistence_agent.Agent | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return result.first() + + async def dry_run_event_route( + self, + bot_uuid: str, + event_type: str, + event_data: dict[str, typing.Any] | None = None, + context: dict[str, typing.Any] | None = None, + event_bindings: list[dict[str, typing.Any]] | None = None, + ) -> dict[str, typing.Any]: + """Diagnose Bot event routing without dispatching to Agent, Pipeline, or platform actions.""" + from ....platform.botmgr import RuntimeBot + + event_type = str(event_type or '').strip() + if not event_type: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_INVALID_EVENT, + reason='event_type is required', + diagnostic_steps=[ + { + 'step': 'validate_event', + 'matched': False, + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': 'event_type is required', + } + ], + ) + + bot = await self.get_bot(bot_uuid, include_secret=False) + if bot is None: + raise Exception('Bot not found') + + try: + event = self._build_dry_run_event(event_type, event_data, context) + bindings = ( + self._normalize_dry_run_bindings(event_bindings) + if event_bindings is not None + else self._index_event_bindings( + RuntimeBot._get_event_bindings_from_value(bot.get('event_bindings') or []) + ) + ) + except ValueError as exc: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_INVALID_EVENT, + reason=str(exc), + diagnostic_steps=[ + { + 'step': 'validate_event', + 'matched': False, + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': str(exc), + } + ], + ) + + selected_binding, diagnostic_steps = RuntimeBot._evaluate_eba_event_bindings( + bindings, + event, + event_type, + ) + if selected_binding is None: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_ROUTE_NOT_FOUND, + reason='No enabled event binding matched event_type and filters', + diagnostic_steps=diagnostic_steps, + ) + + target_type = selected_binding.get('target_type') + target_uuid = str(selected_binding.get('target_uuid') or '') + + if target_type == 'discard': + return self._diagnostic_result( + matched=True, + binding=selected_binding, + reason='Event route matched discard target', + diagnostic_steps=diagnostic_steps, + ) + + if target_type == 'pipeline': + if not RuntimeBot._is_message_event_type(event_type): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason='Pipeline targets only support message events', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': 'Pipeline targets only support message events', + } + ], + ) + pipeline = await self._get_pipeline_entity(target_uuid) if target_uuid else None + if pipeline is None: + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_NOT_FOUND, + reason='Pipeline target not found', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_NOT_FOUND, + 'reason': 'Pipeline target not found', + } + ], + ) + return self._diagnostic_result( + matched=True, + binding=selected_binding, + target_name=getattr(pipeline, 'name', None), + reason='Event route matched pipeline target', + diagnostic_steps=diagnostic_steps, + ) + + if target_type == 'agent': + agent = await self._get_agent_entity(target_uuid) + if agent is None or getattr(agent, 'kind', 'agent') != 'agent': + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_NOT_FOUND, + reason='Agent target not found', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_NOT_FOUND, + 'reason': 'Agent target not found', + } + ], + ) + if not getattr(agent, 'enabled', True): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_DISABLED, + reason='Agent target is disabled', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_DISABLED, + 'reason': 'Agent target is disabled', + } + ], + ) + if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason='Agent target does not support this event type', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': 'Agent target does not support this event type', + } + ], + ) + return self._diagnostic_result( + matched=True, + binding=selected_binding, + target_name=getattr(agent, 'name', None), + target_kind=getattr(agent, 'kind', None), + reason='Event route matched agent target', + diagnostic_steps=diagnostic_steps, + ) + + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason=f'Unsupported event binding target type: {target_type}', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': f'Unsupported event binding target type: {target_type}', + } + ], + ) + async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]: """Validate and normalize Bot event bindings.""" if not bindings: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 3846b277e..939e6d560 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -208,8 +208,9 @@ class RuntimeBot: if isinstance(event_filter, dict) ) - def _get_event_bindings(self) -> list[dict[str, typing.Any]]: - raw_bindings = self.bot_entity.event_bindings or [] + @staticmethod + def _get_event_bindings_from_value(raw_bindings: typing.Any) -> list[dict[str, typing.Any]]: + raw_bindings = raw_bindings or [] if isinstance(raw_bindings, str): try: raw_bindings = json.loads(raw_bindings) @@ -219,32 +220,90 @@ class RuntimeBot: return [] return [binding for binding in raw_bindings if isinstance(binding, dict)] + def _get_event_bindings(self) -> list[dict[str, typing.Any]]: + return self._get_event_bindings_from_value(self.bot_entity.event_bindings) + + @classmethod + def _evaluate_eba_event_bindings( + cls, + bindings: list[dict[str, typing.Any]], + event: platform_events.EBAEvent, + event_type: str, + ) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]: + """Evaluate Bot event bindings with the same precedence used at runtime.""" + matched: list[tuple[int, int, dict[str, typing.Any], dict[str, typing.Any]]] = [] + diagnostic_steps: list[dict[str, typing.Any]] = [] + + for index, binding in enumerate(bindings): + event_pattern = str(binding.get('event_pattern') or '') + priority = int(binding.get('priority') or 0) + order = int(binding.get('order', index)) + step = { + 'step': 'evaluate_binding', + 'binding_id': binding.get('id'), + 'event_pattern': event_pattern, + 'target_type': binding.get('target_type'), + 'target_uuid': binding.get('target_uuid') or '', + 'enabled': binding.get('enabled', True), + 'priority': priority, + 'order': order, + 'matched': False, + 'failure_code': None, + 'reason': '', + } + + if not binding.get('enabled', True): + step['failure_code'] = 'binding_disabled' + step['reason'] = 'Binding is disabled' + diagnostic_steps.append(step) + continue + + if not cls._match_event_pattern(event_type, event_pattern): + step['failure_code'] = 'event_pattern_mismatch' + step['reason'] = 'Event type does not match binding event_pattern' + diagnostic_steps.append(step) + continue + if not cls._match_event_filters(event, binding.get('filters')): + step['failure_code'] = 'filters_mismatch' + step['reason'] = 'Event data does not satisfy binding filters' + diagnostic_steps.append(step) + continue + + step['matched'] = True + step['reason'] = 'Binding matched event pattern and filters' + diagnostic_steps.append(step) + matched.append((priority, -order, binding, step)) + + if not matched: + return None, diagnostic_steps + + matched.sort(key=lambda item: (item[0], item[1]), reverse=True) + selected_binding = matched[0][2] + selected_step = matched[0][3] + selected_step['selected'] = True + selected_step['reason'] = 'Selected by priority and order' + for _, _, _, step in matched[1:]: + step['selected'] = False + step['failure_code'] = 'lower_priority' + step['reason'] = 'Another matching binding has higher priority or earlier order' + return selected_binding, diagnostic_steps + def _resolve_eba_event_binding( self, event: platform_events.EBAEvent, event_type: str, ) -> dict[str, typing.Any] | None: """Resolve the highest priority Bot event binding for a platform event.""" - matched: list[tuple[int, int, dict[str, typing.Any]]] = [] - for index, binding in enumerate(self._get_event_bindings()): - if not binding.get('enabled', True): - continue + selected, _ = self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) + return selected - event_pattern = str(binding.get('event_pattern') or '') - if not self._match_event_pattern(event_type, event_pattern): - continue - if not self._match_event_filters(event, binding.get('filters')): - continue - - priority = int(binding.get('priority') or 0) - order = int(binding.get('order', index)) - matched.append((priority, -order, binding)) - - if not matched: - return None - - matched.sort(key=lambda item: (item[0], item[1]), reverse=True) - return matched[0][2] + def diagnose_eba_event_binding( + self, + event: platform_events.EBAEvent, + event_type: str, + ) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]: + """Return the selected event binding plus per-binding diagnostic steps.""" + return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None: """Return the first Pipeline target configured for an event type.""" diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 0e6854bf9..19de8d246 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -97,6 +97,27 @@ def fake_bot_app(): app.bot_service.update_bot = AsyncMock(return_value={}) app.bot_service.delete_bot = AsyncMock() app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) + app.bot_service.dry_run_event_route = AsyncMock( + return_value={ + 'matched': True, + 'binding_id': 'binding-1', + 'matched_binding_id': 'binding-1', + 'matched_binding_index': 0, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'target': { + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'target_name': 'Member Agent', + 'kind': 'agent', + }, + 'reason': 'Event route matched agent target', + 'failure_code': None, + 'diagnostic_steps': ['Route 1 (platform.member.joined) selected: Selected by priority and order'], + 'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}], + } + ) app.bot_service.send_message = AsyncMock() # Platform manager @@ -199,6 +220,54 @@ class TestBotLogsEndpoint: assert 'total_count' in data['data'] +@pytest.mark.usefixtures('mock_circular_import_chain') +class TestBotEventRouteDryRunEndpoint: + """Tests for bot event route dry-run endpoint.""" + + @pytest.mark.asyncio + async def test_dry_run_event_route_success(self, quart_test_client, fake_bot_app): + """POST dry_run returns route diagnostics.""" + response = await quart_test_client.post( + '/api/v1/platform/bots/test-bot-uuid/event-routes/dry-run', + headers={'Authorization': 'Bearer test_token'}, + json={ + 'event_type': 'platform.member.joined', + 'payload': {'room': {'id': 'room-1'}}, + 'context': {'source': 'ui'}, + 'event_bindings': [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ], + }, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['matched'] is True + assert data['data']['binding_id'] == 'binding-1' + assert data['data']['target']['target_name'] == 'Member Agent' + assert data['data']['diagnostic_steps'][0].startswith('Route 1') + fake_bot_app.bot_service.dry_run_event_route.assert_awaited_with( + bot_uuid='test-bot-uuid', + event_type='platform.member.joined', + event_data={'room': {'id': 'room-1'}}, + context={'source': 'ui'}, + event_bindings=[ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ], + ) + + @pytest.mark.usefixtures('mock_circular_import_chain') class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 3632d4eb7..546d44a8f 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -624,6 +624,299 @@ class TestBotServiceEventBindings: assert other_session.using_conversation.bot_uuid == 'other-bot' +class TestBotServiceEventRouteDryRun: + """Tests for dry-run Bot event route diagnostics.""" + + @staticmethod + def _make_service( + event_bindings: list[dict], + *, + bot_uuid: str = 'bot-1', + persistence_results: list[Mock] | None = None, + ) -> BotService: + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.persistence_mgr.execute_async = AsyncMock(side_effect=persistence_results or []) + service = BotService(ap) + service.get_bot = AsyncMock( + return_value={ + 'uuid': bot_uuid, + 'name': 'Dry Run Bot', + 'event_bindings': event_bindings, + } + ) + return service + + async def test_dry_run_event_route_matches_agent(self): + """Matching Agent routes return the selected binding without running the Agent.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 5, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=True, + supported_event_patterns=['platform.member.*'], + ) + ) + ], + ) + + result = await service.dry_run_event_route( + 'bot-1', + 'platform.member.joined', + event_data={'room': {'id': 'room-1'}}, + ) + + assert result['matched'] is True + assert result['binding_id'] == 'agent-binding' + assert result['event_pattern'] == 'platform.member.*' + assert result['target_type'] == 'agent' + assert result['target_uuid'] == 'agent-1' + assert result['target']['target_uuid'] == 'agent-1' + assert result['target']['target_name'] is None + assert result['failure_code'] is None + assert result['matched_binding_id'] == 'agent-binding' + assert result['matched_binding_index'] == 0 + assert result['diagnostic_details'][0]['selected'] is True + assert result['diagnostic_steps'][0].startswith('Route 1') + + async def test_dry_run_event_route_matches_discard(self): + """Discard routes are terminal dry-run matches and do not query processors.""" + service = self._make_service( + [ + { + 'id': 'discard-binding', + 'enabled': True, + 'event_pattern': '*', + 'target_type': 'discard', + 'target_uuid': '', + 'priority': 0, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.left') + + assert result['matched'] is True + assert result['binding_id'] == 'discard-binding' + assert result['target_type'] == 'discard' + assert result['target']['kind'] == 'discard' + assert result['failure_code'] is None + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_returns_route_not_found_when_no_binding_matches(self): + """Pattern/filter misses return route_not_found.""" + service = self._make_service( + [ + { + 'id': 'wrong-event', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] is None + assert result['failure_code'] == 'route_not_found' + assert result['target'] is None + assert result['diagnostic_details'][0]['failure_code'] == 'event_pattern_mismatch' + assert 'skipped' in result['diagnostic_steps'][0] + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_reports_disabled_binding_without_matching(self): + """Disabled bindings are diagnosed but never selected.""" + service = self._make_service( + [ + { + 'id': 'disabled-binding', + 'enabled': False, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 100, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['failure_code'] == 'route_not_found' + assert result['diagnostic_details'][0]['binding_id'] == 'disabled-binding' + assert result['diagnostic_details'][0]['failure_code'] == 'binding_disabled' + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_rejects_pipeline_for_non_message_event(self): + """Pipeline targets cannot dry-run match non-message events.""" + service = self._make_service( + [ + { + 'id': 'pipeline-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'priority': 0, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] == 'pipeline-binding' + assert result['target_type'] == 'pipeline' + assert result['failure_code'] == 'processor_incompatible' + assert result['diagnostic_details'][-1]['step'] == 'validate_processor' + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_rejects_incompatible_agent(self): + """Agent targets must support the incoming event type.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=True, + supported_event_patterns=['message.*'], + ) + ) + ], + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] == 'agent-binding' + assert result['target_type'] == 'agent' + assert result['failure_code'] == 'processor_incompatible' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_incompatible' + + async def test_dry_run_event_route_reports_disabled_agent(self): + """Disabled Agent targets return processor_disabled.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=False, + supported_event_patterns=['*'], + ) + ) + ], + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['failure_code'] == 'processor_disabled' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_disabled' + + async def test_dry_run_event_route_reports_missing_pipeline(self): + """Missing Pipeline targets return processor_not_found.""" + service = self._make_service( + [ + { + 'id': 'pipeline-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[_create_mock_result(first_item=None)], + ) + + result = await service.dry_run_event_route('bot-1', 'message.received') + + assert result['matched'] is False + assert result['failure_code'] == 'processor_not_found' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_not_found' + + async def test_dry_run_event_route_uses_draft_event_bindings(self): + """Draft bindings from the UI can be tested before saving the bot.""" + service = self._make_service( + [ + { + 'id': 'saved-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'discard', + 'priority': 0, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route( + 'bot-1', + 'platform.member.joined', + event_bindings=[ + { + 'id': 'draft-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'discard', + 'enabled': True, + 'priority': 0, + } + ], + ) + + assert result['matched'] is True + assert result['binding_id'] == 'draft-binding' + assert result['matched_binding_index'] == 0 + assert result['target']['kind'] == 'discard' + + class TestBotServiceDeleteBot: """Tests for delete_bot method.""" diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 92e307436..f7d41f552 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -456,6 +456,7 @@ export default function BotForm({ diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index c8c4217ec..6a7e7db96 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -1,21 +1,27 @@ 'use client'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { UseFormReturn } from 'react-hook-form'; import { + Activity, + AlertCircle, ArrowRight, Ban, Bot, Check, + CheckCircle2, ChevronDown, ChevronRight, ChevronsUpDown, GripVertical, + ListChecks, Plus, + Play, Trash2, Workflow, + XCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -39,6 +45,15 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { DndContext, DragOverlay, @@ -59,7 +74,13 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Input } from '@/components/ui/input'; -import { EventBinding, Agent } from '@/app/infra/entities/api'; +import { Textarea } from '@/components/ui/textarea'; +import { + EventBinding, + Agent, + BotRouteDryRunResult, +} from '@/app/infra/entities/api'; +import { backendClient } from '@/app/infra/http'; export const PIPELINE_DISCARD = '__discard__'; @@ -67,6 +88,7 @@ export const PIPELINE_DISCARD = '__discard__'; interface EventBindingsEditorProps { form: UseFormReturn; + botId?: string; supportedEvents: string[]; agentOptions: Agent[]; } @@ -170,10 +192,27 @@ function eventLabel(event: string, t: TFunction) { return label === key ? event : label; } +function eventDescription(event: string, t: TFunction) { + if (event === '*') return t('bots.eventDescriptions.all'); + if (event.endsWith('.*')) return t('bots.eventDescriptions.namespace'); + const key = `bots.eventDescriptions.${event.replace(/\./g, '_')}`; + const description = t(key); + return description === key ? t('bots.eventDescriptions.custom') : description; +} + function targetLabel(agent: Agent) { return `${agent.emoji ? `${agent.emoji} ` : ''}${agent.name}`; } +function targetTypeLabel( + type: EventBinding['target_type'] | undefined, + t: TFunction, +) { + if (type === 'pipeline') return t('bots.targetPipeline'); + if (type === 'discard') return t('bots.targetDiscard'); + return t('bots.targetAgent'); +} + // ── target combobox (type + target merged, with search + groups) ─────────────── // Encoded value: "discard", "agent:", "pipeline:" @@ -464,6 +503,341 @@ function FilterConditionsPanel({ ); } +// ── adapter capability summary ──────────────────────────────────────────────── + +function AdapterCapabilitySummary({ + supportedEvents, + eventOptions, +}: { + supportedEvents: string[]; + eventOptions: string[]; +}) { + const { t } = useTranslation(); + const [advancedOpen, setAdvancedOpen] = useState(false); + const concreteEvents = + supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS; + const previewEvents = concreteEvents.slice(0, 4); + + return ( +
+
+
+
+ + + +
+

+ {t('bots.adapterEventsTitle')} +

+

+ {t('bots.adapterEventsDescription', { + count: concreteEvents.length, + })} +

+
+
+
+ {previewEvents.map((event) => ( + + {eventLabel(event, t)} + + ))} + {concreteEvents.length > previewEvents.length && ( + + {t('bots.adapterEventsMore', { + count: concreteEvents.length - previewEvents.length, + })} + + )} +
+
+ +
+ {advancedOpen && ( +
+ {eventOptions.map((event) => ( +
+
+ + {eventLabel(event, t)} + + {event.endsWith('.*') && ( + + {t('bots.eventGroup')} + + )} +
+

+ {eventDescription(event, t)} +

+ + {event} + +
+ ))} +
+ )} +
+ ); +} + +// ── route dry-run dialog ───────────────────────────────────────────────────── + +function RouteDryRunDialog({ + botId, + bindings, + eventOptions, + agentOptions, +}: { + botId?: string; + bindings: EventBinding[]; + eventOptions: string[]; + agentOptions: Agent[]; +}) { + const { t } = useTranslation(); + const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0]; + const [open, setOpen] = useState(false); + const [eventType, setEventType] = useState(firstEvent); + const [payloadText, setPayloadText] = useState('{\n "message_text": ""\n}'); + const [isRunning, setIsRunning] = useState(false); + const [payloadError, setPayloadError] = useState(null); + const [runError, setRunError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!eventOptions.includes(eventType)) { + setEventType(firstEvent); + } + }, [eventOptions, eventType, firstEvent]); + + function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) { + if (!resultTarget) return ''; + if (resultTarget.target_name) return resultTarget.target_name; + if (resultTarget.target_type === 'discard') return t('bots.targetDiscard'); + const agent = agentOptions.find((a) => a.uuid === resultTarget.target_uuid); + return agent ? targetLabel(agent) : (resultTarget.target_uuid ?? ''); + } + + async function runDryRun() { + setPayloadError(null); + setRunError(null); + setResult(null); + + let payload: Record = {}; + if (payloadText.trim()) { + try { + const parsed = JSON.parse(payloadText); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + setPayloadError(t('bots.dryRunPayloadObjectError')); + return; + } + payload = parsed as Record; + } catch { + setPayloadError(t('bots.dryRunPayloadJsonError')); + return; + } + } + + if (!botId) { + setRunError(t('bots.dryRunNeedsSavedBot')); + return; + } + + setIsRunning(true); + try { + const dryRunResult = await backendClient.dryRunBotEventRoute(botId, { + event_type: eventType, + payload, + event_bindings: bindings, + }); + setResult({ + ...dryRunResult, + diagnostic_steps: dryRunResult.diagnostic_steps ?? [], + }); + } catch (error) { + const err = error as { msg?: string }; + setRunError(err.msg || t('bots.dryRunFailed')); + } finally { + setIsRunning(false); + } + } + + const targetName = result ? resolveTargetName(result.target) : ''; + + return ( + <> + + + + + {t('bots.dryRunTitle')} + {t('bots.dryRunDescription')} + + +
+
+
+ + +
+
+ +