feat(bots): bind plugin processor configurations independently

This commit is contained in:
RockChinQ
2026-09-15 00:28:13 +08:00
parent e913658e03
commit 184037a427
27 changed files with 1253 additions and 82 deletions
@@ -0,0 +1,107 @@
"""Independent bot subscriptions must not change primary route delivery."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
from langbot.pkg.platform.botmgr import RuntimeBot
def make_bot(subscriptions):
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(uuid='bot', event_bindings=[], plugin_processors=subscriptions)
bot.execution_context = 'workspace'
bot._record_adapter_event = AsyncMock()
bot._record_event_route_trace = AsyncMock()
bot.logger = SimpleNamespace(error=AsyncMock())
return bot
async def test_slow_failed_and_duplicate_subscriptions_do_not_block_primary_route():
bot = make_bot(
[
{'processor_uuid': 'slow'},
{'processor_uuid': 'failed'},
{'processor_uuid': 'fast'},
{'processor_uuid': 'fast'},
{'processor_uuid': 'disabled', 'enabled': False},
]
)
started = []
release = asyncio.Event()
async def subscriber(event, adapter, uuid):
started.append(uuid)
if uuid == 'slow':
await release.wait()
if uuid == 'failed':
raise ValueError('plugin error')
async def primary(event, adapter):
started.append('primary')
bot._dispatch_plugin_subscription = subscriber
bot._dispatch_eba_event_to_processor = primary
task = asyncio.create_task(bot._handle_platform_event(MemberJoinedEvent(), None))
for _ in range(10):
await asyncio.sleep(0)
if len(started) == 4:
break
assert set(started) == {'primary', 'slow', 'failed', 'fast'}
assert started.count('fast') == 1
assert not task.done()
release.set()
await task
bot.logger.error.assert_awaited_once()
@pytest.mark.parametrize(
'patterns,event_usage,expected',
[
(['group.member_joined'], True, 1),
(['group.*'], True, 1),
(['*'], True, 1),
(['message.received'], True, 0),
([], True, 0),
(['*'], False, 0),
],
)
async def test_matching_uses_live_runner_declaration(patterns, event_usage, expected):
bot = make_bot([])
bot.ap = SimpleNamespace(
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'kind': 'event_processor',
'component_ref': 'plugin:test/runner/default',
'supported_event_patterns': ['stale.event'],
}
)
),
runner_registry=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
usages=['event'] if event_usage else ['agent'],
supported_event_patterns=patterns,
)
)
),
)
bot._dispatch_eba_event_to_processor = AsyncMock()
await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'processor')
assert bot._dispatch_eba_event_to_processor.await_count == expected
if expected:
args = bot._dispatch_eba_event_to_processor.await_args.args
assert args[2]['target_uuid'] == 'processor'
assert args[3]['supported_event_patterns'] == patterns
async def test_missing_processor_is_logged_without_unscoped_lookup():
bot = make_bot([])
bot.ap = SimpleNamespace(agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=None)))
await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'missing')
bot.ap.agent_service.get_agent.assert_awaited_once_with('workspace', 'missing')
assert bot._record_event_route_trace.await_args.kwargs['status'] == 'failed'
@@ -712,19 +712,8 @@ async def test_installed_event_processor_never_receives_unbound_events():
async def test_bound_event_processor_receives_one_complete_typed_event():
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
bot = TestEventRouteTrace._make_bot(
[
{
'id': 'binding',
'enabled': True,
'event_pattern': 'group.member_joined',
'target_type': 'event_processor',
'target_uuid': 'processor-1',
'priority': 0,
'order': 0,
}
]
)
bot = TestEventRouteTrace._make_bot([])
bot.bot_entity.plugin_processors = [{'processor_uuid': 'processor-1', 'enabled': True}]
calls = []
async def run(envelope, binding, adapter_context=None):
@@ -748,6 +737,9 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
agent_run_orchestrator=SimpleNamespace(run=run),
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
)
bot.ap.runner_registry = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(usages=['event'], supported_event_patterns=['group.member_joined']))
)
bot._record_adapter_event = AsyncMock()
await bot._handle_platform_event(
MemberJoinedEvent(
@@ -836,7 +828,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
)
trace = await bot._dispatch_eba_event_to_processor(event, adapter)
trace = await bot._dispatch_eba_event_to_processor(
event, adapter, bot.bot_entity.event_bindings[0] if kind == 'event_processor' else None
)
assert trace['status'] == ('failed' if runner_fails else 'delivered')
if runner_fails: