mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-15 06:50:58 +00:00
test(agent): cover agent service event bindings
This commit is contained in:
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.service.agent import (
|
||||||
|
AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
AGENT_KIND_AGENT,
|
||||||
|
AGENT_KIND_PIPELINE,
|
||||||
|
PIPELINE_EVENT_PATTERNS,
|
||||||
|
AgentService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _result(items: list | None = None, first_item=None):
|
||||||
|
result = Mock()
|
||||||
|
result.all = Mock(return_value=items or [])
|
||||||
|
result.first = Mock(return_value=first_item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_row(
|
||||||
|
agent_uuid: str = 'agent-1',
|
||||||
|
name: str = 'Test Agent',
|
||||||
|
updated_at: dt.datetime | str | None = None,
|
||||||
|
config: dict | None = None,
|
||||||
|
supported_event_patterns: list[str] | None = None,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
uuid=agent_uuid,
|
||||||
|
name=name,
|
||||||
|
description='Agent description',
|
||||||
|
emoji='A',
|
||||||
|
kind=AGENT_KIND_AGENT,
|
||||||
|
component_ref='plugin:test/runner/default',
|
||||||
|
config=config or {
|
||||||
|
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||||
|
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||||
|
},
|
||||||
|
enabled=True,
|
||||||
|
supported_event_patterns=supported_event_patterns or ['*'],
|
||||||
|
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
||||||
|
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_agent(model_cls, entity, masked_columns=None):
|
||||||
|
return {
|
||||||
|
'uuid': entity.uuid,
|
||||||
|
'name': entity.name,
|
||||||
|
'description': entity.description,
|
||||||
|
'emoji': entity.emoji,
|
||||||
|
'kind': entity.kind,
|
||||||
|
'component_ref': entity.component_ref,
|
||||||
|
'config': entity.config,
|
||||||
|
'enabled': entity.enabled,
|
||||||
|
'supported_event_patterns': entity.supported_event_patterns,
|
||||||
|
'created_at': entity.created_at,
|
||||||
|
'updated_at': entity.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compiled_params(statement):
|
||||||
|
return statement.compile().params
|
||||||
|
|
||||||
|
|
||||||
|
def _compiled_update_values(statement):
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in statement.compile().params.items()
|
||||||
|
if not key.startswith('uuid_')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app():
|
||||||
|
app = SimpleNamespace()
|
||||||
|
app.persistence_mgr = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(),
|
||||||
|
serialize_model=Mock(side_effect=_serialize_agent),
|
||||||
|
)
|
||||||
|
app.pipeline_service = SimpleNamespace(
|
||||||
|
get_pipeline_metadata=AsyncMock(return_value=[]),
|
||||||
|
get_pipelines=AsyncMock(return_value=[]),
|
||||||
|
get_pipeline=AsyncMock(return_value=None),
|
||||||
|
create_pipeline=AsyncMock(),
|
||||||
|
update_pipeline=AsyncMock(),
|
||||||
|
delete_pipeline=AsyncMock(),
|
||||||
|
_get_default_values_from_schema=Mock(return_value={}),
|
||||||
|
)
|
||||||
|
app.agent_runner_registry = None
|
||||||
|
app.logger = Mock()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentServiceMetadata:
|
||||||
|
async def test_get_agent_metadata_exposes_runner_config_and_kind_capabilities(self):
|
||||||
|
app = _make_app()
|
||||||
|
ai_metadata = {'name': 'ai', 'stages': [{'name': 'runner'}]}
|
||||||
|
app.pipeline_service.get_pipeline_metadata = AsyncMock(
|
||||||
|
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = await AgentService(app).get_agent_metadata()
|
||||||
|
|
||||||
|
assert metadata['runner_config'] == ai_metadata
|
||||||
|
assert metadata['kinds'] == [
|
||||||
|
{
|
||||||
|
'name': AGENT_KIND_AGENT,
|
||||||
|
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
'message_only': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': AGENT_KIND_PIPELINE,
|
||||||
|
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||||
|
'message_only': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentServiceListAndLookup:
|
||||||
|
async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self):
|
||||||
|
app = _make_app()
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
return_value=_result(
|
||||||
|
items=[
|
||||||
|
_agent_row(
|
||||||
|
agent_uuid='agent-1',
|
||||||
|
updated_at=dt.datetime(2026, 1, 1, 10, 0, 0),
|
||||||
|
supported_event_patterns=['platform.member.*'],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
app.pipeline_service.get_pipelines = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
'uuid': 'pipeline-1',
|
||||||
|
'name': 'Pipeline Agent',
|
||||||
|
'description': 'Legacy pipeline',
|
||||||
|
'emoji': 'P',
|
||||||
|
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
|
||||||
|
'created_at': '2026-01-01T08:00:00',
|
||||||
|
'updated_at': '2026-01-01T11:00:00',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agents = await AgentService(app).get_agents(sort_by='updated_at', sort_order='DESC')
|
||||||
|
|
||||||
|
assert [agent['uuid'] for agent in agents] == ['pipeline-1', 'agent-1']
|
||||||
|
assert agents[0]['kind'] == AGENT_KIND_PIPELINE
|
||||||
|
assert agents[0]['component_ref'] == 'pipeline'
|
||||||
|
assert agents[0]['capability'] == {
|
||||||
|
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||||
|
'message_only': True,
|
||||||
|
}
|
||||||
|
assert agents[1]['kind'] == AGENT_KIND_AGENT
|
||||||
|
assert agents[1]['capability'] == {
|
||||||
|
'supported_event_patterns': ['platform.member.*'],
|
||||||
|
'message_only': False,
|
||||||
|
}
|
||||||
|
assert all('config' not in agent for agent in agents)
|
||||||
|
|
||||||
|
async def test_get_agent_returns_agent_with_config_before_pipeline_fallback(self):
|
||||||
|
app = _make_app()
|
||||||
|
agent = _agent_row(agent_uuid='agent-1')
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=agent))
|
||||||
|
|
||||||
|
result = await AgentService(app).get_agent('agent-1')
|
||||||
|
|
||||||
|
assert result['uuid'] == 'agent-1'
|
||||||
|
assert result['kind'] == AGENT_KIND_AGENT
|
||||||
|
assert result['config'] == agent.config
|
||||||
|
app.pipeline_service.get_pipeline.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_get_agent_falls_back_to_pipeline_product_item_with_config(self):
|
||||||
|
app = _make_app()
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
|
||||||
|
app.pipeline_service.get_pipeline = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'pipeline-1',
|
||||||
|
'name': 'Pipeline Agent',
|
||||||
|
'description': 'Legacy pipeline',
|
||||||
|
'emoji': 'P',
|
||||||
|
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
|
||||||
|
'created_at': '2026-01-01T08:00:00',
|
||||||
|
'updated_at': '2026-01-01T11:00:00',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AgentService(app).get_agent('pipeline-1')
|
||||||
|
|
||||||
|
assert result['kind'] == AGENT_KIND_PIPELINE
|
||||||
|
assert result['enabled'] is True
|
||||||
|
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
|
||||||
|
assert result['capability']['message_only'] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentServiceCreateUpdateDelete:
|
||||||
|
async def test_create_agent_uses_default_runner_config_from_registry(self):
|
||||||
|
app = _make_app()
|
||||||
|
runner = SimpleNamespace(
|
||||||
|
id='plugin:langbot/local-agent/default',
|
||||||
|
config_schema=[
|
||||||
|
{'name': 'model', 'default': 'gpt-4.1'},
|
||||||
|
{'name': 'temperature', 'default': 0.2},
|
||||||
|
{'name': 'no-default'},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner]))
|
||||||
|
app.pipeline_service._get_default_values_from_schema = Mock(
|
||||||
|
return_value={'model': 'gpt-4.1', 'temperature': 0.2}
|
||||||
|
)
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
|
result = await AgentService(app).create_agent(
|
||||||
|
{
|
||||||
|
'name': 'Support Agent',
|
||||||
|
'description': 'Handles support events',
|
||||||
|
'emoji': 'S',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
|
assert result['kind'] == AGENT_KIND_AGENT
|
||||||
|
assert result['uuid'] == insert_values['uuid']
|
||||||
|
assert insert_values['name'] == 'Support Agent'
|
||||||
|
assert insert_values['component_ref'] == runner.id
|
||||||
|
assert insert_values['config'] == {
|
||||||
|
'runner': {'id': runner.id, 'expire-time': 0},
|
||||||
|
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
|
||||||
|
}
|
||||||
|
assert insert_values['enabled'] is True
|
||||||
|
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
|
||||||
|
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
|
||||||
|
|
||||||
|
async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self):
|
||||||
|
app = _make_app()
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_result(first_item=_agent_row(agent_uuid='agent-1')),
|
||||||
|
Mock(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
new_config = {
|
||||||
|
'runner': {'id': 'plugin:test/new-runner/default', 'expire-time': 0},
|
||||||
|
'runner_config': {'plugin:test/new-runner/default': {'timeout': 30}},
|
||||||
|
}
|
||||||
|
|
||||||
|
await AgentService(app).update_agent(
|
||||||
|
'agent-1',
|
||||||
|
{
|
||||||
|
'uuid': 'caller-owned-uuid',
|
||||||
|
'kind': AGENT_KIND_PIPELINE,
|
||||||
|
'created_at': '2020-01-01T00:00:00',
|
||||||
|
'updated_at': '2020-01-01T00:00:00',
|
||||||
|
'capability': {'message_only': True},
|
||||||
|
'name': 'Updated Agent',
|
||||||
|
'config': new_config,
|
||||||
|
'supported_event_patterns': [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||||
|
assert update_values == {
|
||||||
|
'name': 'Updated Agent',
|
||||||
|
'config': new_config,
|
||||||
|
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
|
'component_ref': 'plugin:test/new-runner/default',
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self):
|
||||||
|
app = _make_app()
|
||||||
|
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
|
||||||
|
app.pipeline_service.create_pipeline = AsyncMock(return_value='pipeline-created')
|
||||||
|
app.pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipeline-1'})
|
||||||
|
service = AgentService(app)
|
||||||
|
|
||||||
|
created = await service.create_agent(
|
||||||
|
{
|
||||||
|
'kind': AGENT_KIND_PIPELINE,
|
||||||
|
'name': 'Pipeline Agent',
|
||||||
|
'description': 'Legacy pipeline',
|
||||||
|
'emoji': 'P',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await service.update_agent('pipeline-1', {'name': 'Updated Pipeline'})
|
||||||
|
await service.delete_agent('pipeline-1')
|
||||||
|
|
||||||
|
assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE}
|
||||||
|
app.pipeline_service.create_pipeline.assert_awaited_once_with(
|
||||||
|
{
|
||||||
|
'name': 'Pipeline Agent',
|
||||||
|
'description': 'Legacy pipeline',
|
||||||
|
'emoji': 'P',
|
||||||
|
'config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
app.pipeline_service.update_pipeline.assert_awaited_once_with(
|
||||||
|
'pipeline-1',
|
||||||
|
{'name': 'Updated Pipeline'},
|
||||||
|
)
|
||||||
|
app.pipeline_service.delete_pipeline.assert_awaited_once_with('pipeline-1')
|
||||||
@@ -52,6 +52,15 @@ def _create_mock_result(items: list = None, first_item=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _compiled_update_values(statement):
|
||||||
|
"""Return update values without SQLAlchemy WHERE bind params."""
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in statement.compile().params.items()
|
||||||
|
if not key.startswith('uuid_')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
|
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
|
||||||
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
|
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
|
||||||
|
|
||||||
@@ -531,6 +540,201 @@ class TestBotServiceUpdateBot:
|
|||||||
assert update_params['use_pipeline_name'] == 'Updated Pipeline'
|
assert update_params['use_pipeline_name'] == 'Updated Pipeline'
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceEventBindings:
|
||||||
|
"""Tests for EBA event binding validation and persistence."""
|
||||||
|
|
||||||
|
async def test_normalize_event_bindings_validates_targets_and_preserves_order(self):
|
||||||
|
"""Valid bindings are normalized with stable order and target data."""
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace()
|
||||||
|
ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')),
|
||||||
|
_create_mock_result(
|
||||||
|
first_item=SimpleNamespace(
|
||||||
|
uuid='agent-1',
|
||||||
|
supported_event_patterns=['platform.member.*'],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
|
||||||
|
normalized = await service._normalize_event_bindings(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'event_pattern': ' message.received ',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': ' pipeline-1 ',
|
||||||
|
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
|
||||||
|
'priority': '5',
|
||||||
|
'enabled': False,
|
||||||
|
'description': 'Route message events',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'agent-binding',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'priority': 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'event_pattern': 'platform.member.left',
|
||||||
|
'target_type': 'discard',
|
||||||
|
'target_uuid': 'ignored-target',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
uuid.UUID(normalized[0]['id'])
|
||||||
|
assert normalized == [
|
||||||
|
{
|
||||||
|
'id': normalized[0]['id'],
|
||||||
|
'event_pattern': 'message.received',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': 'pipeline-1',
|
||||||
|
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
|
||||||
|
'priority': 5,
|
||||||
|
'enabled': False,
|
||||||
|
'description': 'Route message events',
|
||||||
|
'order': 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'agent-binding',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'filters': [],
|
||||||
|
'priority': 7,
|
||||||
|
'enabled': True,
|
||||||
|
'description': '',
|
||||||
|
'order': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': normalized[2]['id'],
|
||||||
|
'event_pattern': 'platform.member.left',
|
||||||
|
'target_type': 'discard',
|
||||||
|
'target_uuid': '',
|
||||||
|
'filters': [],
|
||||||
|
'priority': 0,
|
||||||
|
'enabled': True,
|
||||||
|
'description': '',
|
||||||
|
'order': 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_normalize_event_bindings_rejects_pipeline_for_non_message_event(self):
|
||||||
|
"""Pipeline targets are limited to message events."""
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||||
|
service = BotService(ap)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Pipeline can only be bound to message events'):
|
||||||
|
await service._normalize_event_bindings(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': 'pipeline-1',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('agent', 'error'),
|
||||||
|
[
|
||||||
|
(None, 'Agent not found'),
|
||||||
|
(
|
||||||
|
SimpleNamespace(uuid='agent-1', supported_event_patterns=['message.*']),
|
||||||
|
'Agent does not support this event pattern',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_normalize_event_bindings_rejects_invalid_agent_target(self, agent, error):
|
||||||
|
"""Agent targets must exist and support the requested event pattern."""
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(return_value=_create_mock_result(first_item=agent))
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=error):
|
||||||
|
await service._normalize_event_bindings(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_update_bot_persists_normalized_event_bindings_and_reloads_runtime_bot(self):
|
||||||
|
"""update_bot stores normalized bindings before reloading the runtime bot."""
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace()
|
||||||
|
ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(
|
||||||
|
first_item=SimpleNamespace(
|
||||||
|
uuid='agent-1',
|
||||||
|
supported_event_patterns=['platform.member.*'],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Mock(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
|
||||||
|
loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'}
|
||||||
|
ap.platform_mgr = SimpleNamespace(
|
||||||
|
remove_bot=AsyncMock(),
|
||||||
|
load_bot=AsyncMock(return_value=runtime_bot),
|
||||||
|
)
|
||||||
|
bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1'))
|
||||||
|
other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot'))
|
||||||
|
ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session])
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(return_value=loaded_bot)
|
||||||
|
|
||||||
|
await service.update_bot(
|
||||||
|
'bot-1',
|
||||||
|
{
|
||||||
|
'event_bindings': [
|
||||||
|
{
|
||||||
|
'id': 'binding-1',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'priority': '9',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||||
|
assert update_values['event_bindings'] == [
|
||||||
|
{
|
||||||
|
'id': 'binding-1',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'filters': [],
|
||||||
|
'priority': 9,
|
||||||
|
'enabled': True,
|
||||||
|
'description': '',
|
||||||
|
'order': 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ap.platform_mgr.remove_bot.assert_awaited_once_with('bot-1')
|
||||||
|
service.get_bot.assert_awaited_once_with('bot-1')
|
||||||
|
ap.platform_mgr.load_bot.assert_awaited_once_with(loaded_bot)
|
||||||
|
runtime_bot.run.assert_awaited_once()
|
||||||
|
assert bot_session.using_conversation is None
|
||||||
|
assert other_session.using_conversation.bot_uuid == 'other-bot'
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceDeleteBot:
|
class TestBotServiceDeleteBot:
|
||||||
"""Tests for delete_bot method."""
|
"""Tests for delete_bot method."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
|
||||||
@@ -278,3 +279,108 @@ class TestResolvePipelineUuid:
|
|||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
||||||
assert uuid == 'default-uuid'
|
assert uuid == 'default-uuid'
|
||||||
assert routed is False
|
assert routed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEBAEventBindings:
|
||||||
|
"""Test RuntimeBot EBA event binding helpers."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bot(bindings):
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
bot = object.__new__(RuntimeBot)
|
||||||
|
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
|
||||||
|
"""The selected binding is the first matching highest-priority binding."""
|
||||||
|
bot = self._make_bot(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': 'disabled',
|
||||||
|
'enabled': False,
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-disabled',
|
||||||
|
'priority': 100,
|
||||||
|
'order': 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'wrong-room',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-wrong-room',
|
||||||
|
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-2'}],
|
||||||
|
'priority': 50,
|
||||||
|
'order': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'first-high',
|
||||||
|
'event_pattern': 'platform.member.joined',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-first',
|
||||||
|
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
|
||||||
|
'priority': 10,
|
||||||
|
'order': 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'second-high',
|
||||||
|
'event_pattern': 'platform.member.*',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-second',
|
||||||
|
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
|
||||||
|
'priority': 10,
|
||||||
|
'order': 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'fallback',
|
||||||
|
'event_pattern': '*',
|
||||||
|
'target_type': 'discard',
|
||||||
|
'priority': 1,
|
||||||
|
'order': 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = bot._resolve_eba_event_binding(
|
||||||
|
{'room': {'id': 'room-1'}},
|
||||||
|
'platform.member.joined',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selected['id'] == 'first-high'
|
||||||
|
assert selected['target_uuid'] == 'agent-first'
|
||||||
|
|
||||||
|
def test_agent_product_to_binding_projects_runner_config_and_policies(self):
|
||||||
|
"""Agent products become bot-scoped runner bindings for EBA dispatch."""
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
binding = RuntimeBot._agent_product_to_binding(
|
||||||
|
{
|
||||||
|
'uuid': 'agent-1',
|
||||||
|
'component_ref': 'plugin:test/fallback/default',
|
||||||
|
'config': {
|
||||||
|
'runner': {'id': 'plugin:test/runner/default'},
|
||||||
|
'runner_config': {
|
||||||
|
'plugin:test/runner/default': {
|
||||||
|
'temperature': 0.2,
|
||||||
|
'max_tokens': 1000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{'id': 'binding-1'},
|
||||||
|
'platform.member.joined',
|
||||||
|
'bot-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert binding is not None
|
||||||
|
assert binding.binding_id == 'bot:bot-1:binding-1'
|
||||||
|
assert binding.scope.scope_type == 'bot'
|
||||||
|
assert binding.scope.scope_id == 'bot-1'
|
||||||
|
assert binding.event_types == ['platform.member.joined']
|
||||||
|
assert binding.runner_id == 'plugin:test/runner/default'
|
||||||
|
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
|
||||||
|
assert binding.delivery_policy.enable_streaming is False
|
||||||
|
assert binding.delivery_policy.enable_reply is True
|
||||||
|
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
|
||||||
|
assert binding.agent_id == 'agent-1'
|
||||||
|
|||||||
Reference in New Issue
Block a user