mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
feat(bots): bind plugin processor configurations independently
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user