feat(agent-platform): add bot route dry-run

This commit is contained in:
huanghuoguoguo
2026-07-08 14:25:35 +08:00
parent 2004ac0dfc
commit 4e9464dd19
12 changed files with 1466 additions and 32 deletions
+69
View File
@@ -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."""
@@ -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."""