feat(processors): add explicitly bound plugin event processors

This commit is contained in:
RockChinQ
2026-09-08 00:43:35 +08:00
parent 812eb09ee4
commit 237fa6545d
50 changed files with 1968 additions and 289 deletions
+28
View File
@@ -335,3 +335,31 @@ class TestDescriptorValidation:
assert descriptor.supports_streaming() is True
assert descriptor.supports_tool_calling() is False
assert descriptor.supports_knowledge_retrieval() is False
@pytest.mark.asyncio
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
ap = FakeApplication()
entries = []
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
entries.append(
{
'plugin_author': 'test',
'plugin_name': 'both',
'runner_name': 'default',
'manifest': {
'id': f'{prefix}:test/both/default',
'name': 'default',
'component_kind': kind,
'label': {'en_US': kind},
'supported_event_patterns': ['group.member_joined'],
},
}
)
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
registry = AgentRunnerRegistry(ap)
agents = await registry.list_runners(TEST_CONTEXT)
processors = await registry.list_runners(TEST_CONTEXT, component_kind='EventProcessor')
assert [item.id for item in agents] == ['plugin:test/both/default']
assert [item.id for item in processors] == ['event_processor:test/both/default']
assert (await registry.get(TEST_CONTEXT, processors[0].id)).component_kind == 'EventProcessor'
@@ -428,3 +428,30 @@ async def test_runner_stats_reports_zero_success_rate_for_failed_only_runner(sto
assert stats[0]['runner_id'] == 'runner-a'
assert stats[0]['failed_runs'] == 1
assert stats[0]['success_rate'] == 0.0
@pytest.mark.asyncio
async def test_processor_instance_history_filters_count_and_pages(store):
for index, (workspace, binding) in enumerate(
[
('one', 'processor-a'),
('one', 'processor-b'),
('two', 'processor-a'),
('one', 'processor-a'),
]
):
await store.create_run(
run_id=f'run-{index}',
event_id=f'event-{index}',
binding_id=binding,
runner_id='same-component',
workspace_id=workspace,
)
first, cursor, more, total = await store.list_runs(workspace_id='one', binding_id='processor-a', limit=1)
assert (total, more) == (2, True)
assert first[0]['run_id'] == 'run-3'
second, _, more, total = await store.list_runs(
workspace_id='one', binding_id='processor-a', limit=1, before_id=cursor
)
assert (total, more) == (2, False)
assert second[0]['run_id'] == 'run-0'
@@ -145,6 +145,7 @@ class TestAgentServiceMetadata:
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
},
{'name': 'event_processor', 'supported_event_patterns': ['*'], 'message_only': False},
]
@@ -767,3 +768,77 @@ class TestAgentServiceCreateUpdateDelete:
WORKSPACE_UUID,
'pipeline-1',
)
async def test_event_processor_creation_uses_installed_component_scope():
app = _make_app()
ref = 'event_processor:test/welcome/default'
descriptor = SimpleNamespace(
component_kind='EventProcessor',
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
app.agent_runner_registry = SimpleNamespace(get=AsyncMock(return_value=descriptor))
service = AgentService(app)
result = await service.create_agent(
WORKSPACE_UUID,
{
'kind': 'event_processor',
'name': 'Welcome',
'component_ref': ref,
'parameters': {'greeting': 'Hi'},
'supported_event_patterns': ['*'],
},
)
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
assert result['kind'] == 'event_processor'
assert values['component_ref'] == ref
assert values['supported_event_patterns'] == ['group.member_joined']
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
app = _make_app()
service = AgentService(app)
with pytest.raises(ValueError, match='Select an installed'):
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'})
app.agent_runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='EventProcessor',
supported_event_patterns=['*'],
config_schema=[{'name': 'greeting', 'required': True}],
)
)
)
with pytest.raises(ValueError, match='Required processor parameter'):
await service.create_agent(
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'event_processor:a/b/c'}
)
async def test_unavailable_event_processor_can_still_be_renamed():
app = _make_app()
row = _agent_row(config={'runner': {'id': 'event_processor:a/b/c'}, 'runner_config': {'event_processor:a/b/c': {}}})
row.kind = 'event_processor'
row.component_ref = 'event_processor:a/b/c'
service = AgentService(app)
service._get_agent_row = AsyncMock(return_value=row)
await service.update_agent(WORKSPACE_UUID, row.uuid, {'name': 'Renamed'})
assert _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])['name'] == 'Renamed'
@pytest.mark.parametrize(
'workspace,binding', [('other-workspace', 'event_processor:one'), (WORKSPACE_UUID, 'event_processor:two')]
)
async def test_processor_trace_rejects_other_workspace_or_instance(monkeypatch, workspace, binding):
service = AgentService(_make_app())
service.get_agent = AsyncMock(return_value={'kind': 'event_processor'})
service.ap.persistence_mgr.get_db_engine = Mock()
store = SimpleNamespace(
get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding}), page_run_events=AsyncMock()
)
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
with pytest.raises(ValueError, match='Processor run not found'):
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run')
store.page_run_events.assert_not_called()
@@ -692,3 +692,73 @@ def test_websocket_task_override_does_not_mutate_bot_default():
assert pipeline_uuid == 'connection-pipeline'
assert routed is False
assert bot.bot_entity.use_pipeline_uuid == 'default-uuid'
@pytest.mark.asyncio
async def test_installed_event_processor_never_receives_unbound_events():
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
bot = TestEventRouteTrace._make_bot([])
bot.ap = SimpleNamespace(plugin_connector=SimpleNamespace(emit_event=AsyncMock()))
bot._record_adapter_event = AsyncMock()
await bot._handle_platform_event(MemberJoinedEvent(), Mock())
bot.ap.plugin_connector.emit_event.assert_not_called()
assert bot.logger.info.await_args.kwargs['metadata']['status'] == 'not_matched'
@pytest.mark.asyncio
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,
}
]
)
calls = []
async def run(envelope, binding, adapter_context=None):
calls.append((envelope, binding))
if False:
yield None
ref = 'event_processor:test/welcome/default'
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'processor-1',
'kind': 'event_processor',
'supported_event_patterns': ['group.member_joined'],
'config': {'runner': {'id': ref}, 'runner_config': {ref: {'greeting': 'Hi'}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(run=run),
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
)
bot._record_adapter_event = AsyncMock()
await bot._handle_platform_event(
MemberJoinedEvent(
member={'id': 'member-1'}, group={'id': 'group-1'}, source_platform_object={'private': 'opaque'}
),
Mock(),
)
assert len(calls) == 1
envelope, binding = calls[0]
assert binding.binding_id == 'event_processor:processor-1'
assert binding.processor_type == 'event_processor'
assert binding.runner_config == {'greeting': 'Hi'}
assert envelope.data['member']['id'] == 'member-1'
assert envelope.data['type'] == 'group.member_joined'
assert 'source_platform_object' not in envelope.data
bot.ap.plugin_connector.emit_event.assert_not_called()