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,75 @@
"""Validate subscription identity, workspace boundaries and persisted updates."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.agent.runner.errors import RunnerNotFoundError
def make_service(agent=None):
service = BotService(
SimpleNamespace(
runner_registry=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
usages=['event'],
supported_event_patterns=['group.member_joined'],
)
)
)
)
)
service._get_agent_entity = AsyncMock(return_value=agent)
return service
async def test_prepare_accepts_configured_instance_and_preserves_input():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='plugin:test/runner/default'))
payload = {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True, 'events': ['*']}]}
result = await service._prepare_bot_data('workspace', payload, include_uuid=False)
assert result == {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True}]}
assert payload['plugin_processors'][0]['events'] == ['*']
service._get_agent_entity.assert_awaited_once_with('workspace', 'processor')
service.ap.runner_registry.get.assert_awaited_once_with('workspace', 'plugin:test/runner/default')
@pytest.mark.parametrize(
'items,reason',
[
({}, 'must be an array'),
([None], 'must be an object'),
([{}], 'UUID is required'),
([{'processor_uuid': 'p', 'enabled': 'false'}], 'must be a boolean'),
([{'processor_uuid': 'p'}, {'processor_uuid': 'p'}], 'only be bound once'),
],
)
async def test_invalid_bindings_are_rejected(items, reason):
service = make_service(SimpleNamespace(kind='event_processor', component_ref='runner'))
with pytest.raises(ValueError, match=reason):
await service._normalize_plugin_processors('workspace', items)
@pytest.mark.parametrize('agent', [None, SimpleNamespace(kind='agent')])
async def test_missing_cross_workspace_or_wrong_kind_is_rejected(agent):
service = make_service(agent)
with pytest.raises(ValueError, match='not found'):
await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}])
async def test_can_disable_binding_when_plugin_is_unavailable():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing'))
service.ap.runner_registry.get.side_effect = ValueError('not installed')
assert await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p', 'enabled': False}]) == [
{'processor_uuid': 'p', 'enabled': False},
]
service.ap.runner_registry.get.assert_not_called()
async def test_enabling_unavailable_runner_has_actionable_validation_error():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing'))
service.ap.runner_registry.get.side_effect = RunnerNotFoundError('missing')
with pytest.raises(ValueError, match='Runner component is unavailable'):
await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}])
@@ -0,0 +1,49 @@
"""Move existing plugin routes without discarding primary routes or disabled state."""
import importlib
from unittest.mock import patch
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0025_bot_plugin_processors')
@pytest.mark.parametrize('column_exists', [False, True])
def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exists):
engine = sa.create_engine('sqlite://')
metadata = sa.MetaData()
columns = [sa.Column('uuid', sa.String(), primary_key=True), sa.Column('event_bindings', sa.JSON())]
if column_exists:
columns.append(sa.Column('plugin_processors', sa.JSON(), server_default='[]'))
table = sa.Table('bots', metadata, *columns)
metadata.create_all(engine)
primary = {'target_type': 'agent', 'target_uuid': 'agent', 'event_pattern': '*'}
with engine.begin() as conn:
conn.execute(
table.insert().values(
uuid='bot',
event_bindings=[
primary,
{'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': False},
{'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': True},
{'target_type': 'event_processor', 'target_uuid': 'two', 'enabled': False},
],
)
)
with patch.object(migration, 'op', Operations(MigrationContext.configure(conn))):
migration.upgrade()
migrated = sa.Table('bots', sa.MetaData(), autoload_with=conn)
row = conn.execute(sa.select(migrated)).mappings().one()
assert row['event_bindings'] == [primary]
assert row['plugin_processors'] == [
{'processor_uuid': 'one', 'enabled': True},
{'processor_uuid': 'two', 'enabled': False},
]
migration.upgrade()
assert conn.execute(sa.select(migrated)).mappings().one() == row
migration.downgrade()
assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')}
engine.dispose()
@@ -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: