feat(agent-platform): show bot route runtime status

This commit is contained in:
huanghuoguoguo
2026-07-08 16:07:03 +08:00
parent ee11e97e86
commit 4cbb9415bf
13 changed files with 737 additions and 13 deletions
+41
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.list_event_route_statuses = AsyncMock(
return_value={
'routes': [
{
'binding_id': 'binding-1',
'event_pattern': 'platform.member.joined',
'event_type': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'last_status': 'delivered',
'failure_code': None,
'reason': 'delivered',
'timestamp': 100,
'seq_id': 1,
'current': True,
}
],
'unmatched_events': [],
'stale_routes': [],
}
)
app.bot_service.dry_run_event_route = AsyncMock(
return_value={
'matched': True,
@@ -268,6 +289,26 @@ class TestBotEventRouteDryRunEndpoint:
)
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEventRouteStatusEndpoint:
"""Tests for bot event route runtime status endpoint."""
@pytest.mark.asyncio
async def test_get_event_route_status_success(self, quart_test_client, fake_bot_app):
"""GET event route status returns recent route traces."""
response = await quart_test_client.get(
'/api/v1/platform/bots/test-bot-uuid/event-routes/status',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['routes'][0]['binding_id'] == 'binding-1'
assert data['data']['routes'][0]['last_status'] == 'delivered'
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid')
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@@ -997,6 +997,134 @@ class TestBotServiceListEventLogs:
assert total == 5
class TestBotServiceEventRouteStatuses:
"""Tests for event route runtime status aggregation."""
async def test_list_event_route_statuses_bot_not_found_raises(self):
"""Raises Exception when runtime bot not found."""
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
with pytest.raises(Exception, match='Bot not found'):
await service.list_event_route_statuses('missing-bot')
async def test_list_event_route_statuses_merges_latest_trace_by_binding(self):
"""Current route definitions are enriched with the latest trace log."""
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
runtime_bot = SimpleNamespace()
runtime_bot.bot_entity = SimpleNamespace(
event_bindings=[
{
'id': 'binding-1',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'enabled': True,
'order': 0,
},
{
'id': 'binding-2',
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'enabled': False,
'order': 1,
},
]
)
runtime_bot.logger = SimpleNamespace(
logs=[
SimpleNamespace(
to_json=Mock(
return_value={
'seq_id': 1,
'timestamp': 100,
'level': 'info',
'text': 'old matched',
'metadata': {
'kind': 'event_route_trace',
'binding_id': 'binding-1',
'event_pattern': 'platform.member.joined',
'event_type': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'status': 'matched',
'failure_code': None,
'reason': 'matched',
'run_id': None,
},
}
)
),
SimpleNamespace(
to_json=Mock(
return_value={
'seq_id': 2,
'timestamp': 120,
'level': 'error',
'text': 'runner failed',
'metadata': {
'kind': 'event_route_trace',
'binding_id': 'binding-1',
'event_pattern': 'platform.member.joined',
'event_type': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'status': 'failed',
'failure_code': 'runner_failed',
'reason': 'Agent runner failed',
'run_id': None,
},
}
)
),
SimpleNamespace(
to_json=Mock(
return_value={
'seq_id': 3,
'timestamp': 130,
'level': 'info',
'text': 'no route',
'metadata': {
'kind': 'event_route_trace',
'binding_id': None,
'event_pattern': None,
'event_type': 'platform.member.left',
'target_type': None,
'target_uuid': '',
'status': 'not_matched',
'failure_code': 'route_not_found',
'reason': 'No event route matched',
'run_id': None,
},
}
)
),
]
)
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
result = await service.list_event_route_statuses('bot-1')
assert len(result['routes']) == 2
assert result['routes'][0]['binding_id'] == 'binding-1'
assert result['routes'][0]['last_status'] == 'failed'
assert result['routes'][0]['failure_code'] == 'runner_failed'
assert result['routes'][0]['timestamp'] == 120
assert result['routes'][0]['current'] is True
assert result['routes'][1]['binding_id'] == 'binding-2'
assert result['routes'][1]['last_status'] is None
assert result['routes'][1]['enabled'] is False
assert result['unmatched_events'][0]['event_type'] == 'platform.member.left'
assert result['unmatched_events'][0]['failure_code'] == 'route_not_found'
class TestBotServiceSendMessage:
"""Tests for send_message method."""
@@ -3,7 +3,9 @@ RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
"""
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock
import pytest
class TestMatchOperator:
@@ -54,6 +56,90 @@ class TestMatchOperator:
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
class TestEventRouteTrace:
"""Test structured event route trace logging."""
@staticmethod
def _make_bot(event_bindings: list[dict]):
from langbot.pkg.platform.botmgr import RuntimeBot
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(uuid='bot-1', event_bindings=event_bindings)
bot.logger = SimpleNamespace(
info=AsyncMock(),
warning=AsyncMock(),
error=AsyncMock(),
)
return bot
@pytest.mark.asyncio
async def test_dispatch_no_matching_route_records_trace(self):
"""A route miss is visible as structured route trace metadata."""
bot = self._make_bot([])
await bot._dispatch_eba_event_to_agent(SimpleNamespace(type='platform.member.joined'), Mock())
bot.logger.info.assert_awaited_once()
_, kwargs = bot.logger.info.await_args
metadata = kwargs['metadata']
assert metadata['kind'] == 'event_route_trace'
assert metadata['event_type'] == 'platform.member.joined'
assert metadata['status'] == 'not_matched'
assert metadata['failure_code'] == 'route_not_found'
@pytest.mark.asyncio
async def test_record_event_route_trace_includes_binding_and_target(self):
"""Trace metadata preserves binding and processor identifiers."""
bot = self._make_bot([])
await bot._record_event_route_trace(
event_type='platform.member.joined',
status='failed',
level='warning',
binding={
'id': 'binding-1',
'event_pattern': 'platform.member.*',
'target_type': 'agent',
'target_uuid': 'agent-1',
},
failure_code='processor_disabled',
reason='Agent target is disabled',
text='disabled',
)
bot.logger.warning.assert_awaited_once()
_, kwargs = bot.logger.warning.await_args
metadata = kwargs['metadata']
assert metadata['binding_id'] == 'binding-1'
assert metadata['event_pattern'] == 'platform.member.*'
assert metadata['target_type'] == 'agent'
assert metadata['target_uuid'] == 'agent-1'
assert metadata['status'] == 'failed'
class TestEventLoggerMetadata:
"""Test platform EventLogger metadata compatibility."""
@pytest.mark.asyncio
async def test_metadata_is_serialized_without_breaking_no_throw_position(self):
"""Metadata is optional and no_throw remains the fourth positional argument."""
from langbot.pkg.platform.logger import EventLogger
logger = EventLogger(name='test', ap=SimpleNamespace())
await logger.info('plain log', None, None, False)
await logger.info(
'route trace',
metadata={'kind': 'event_route_trace', 'status': 'matched'},
)
assert logger.logs[0].to_json()['metadata'] is None
assert logger.logs[1].to_json()['metadata'] == {
'kind': 'event_route_trace',
'status': 'matched',
}
class TestResolvePipelineUuid:
"""Test the resolve_pipeline_uuid method."""