Files
LangBot/tests/unit_tests/api/service/test_agent_service.py
T
RockChinQandmultica-agent d13ae56ea5 fix: bot routing setup and busy-session response checks (#2728)
Apply group response rules before busy-session steering. Initialize usable private-message and group-mention routes for new bots, including Wizard-created pipelines, and preserve member-configured rules across saves and retries.

Fix empty pipeline configuration, refresh the processor sidebar after automatic creation, and show creation feedback. Align routing regressions and use Node 24 for the browser CI job.

Correct the task's erroneous agent attribution while preserving every code tree and the original human contribution.

Co-authored-by: multica-agent <[email protected]>
2026-10-20 21:22:53 +08:00

956 lines
36 KiB
Python

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,
)
from langbot.pkg.api.http.service.model import LLMModelsService
pytestmark = pytest.mark.asyncio
WORKSPACE_UUID = 'workspace-test'
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(
workspace_uuid=WORKSPACE_UUID,
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}},
},
supported_event_patterns=(supported_event_patterns if supported_event_patterns is not None else ['*']),
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 {
'workspace_uuid': entity.workspace_uuid,
'uuid': entity.uuid,
'name': entity.name,
'description': entity.description,
'emoji': entity.emoji,
'kind': entity.kind,
'component_ref': entity.component_ref,
'config': entity.config,
'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_', 'workspace_uuid_'))
}
def _make_app():
app = SimpleNamespace()
app.llm_model_service = LLMModelsService(app)
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.runner_registry = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(usages=['agent'], config_schema=[])),
list_runners=AsyncMock(return_value=[]),
)
app.tool_mgr = 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'}]
)
host_tools = [
{
'name': 'exec',
'source': 'builtin',
'source_name': 'LangBot',
},
{
'name': 'weather',
'source': 'mcp',
'source_name': 'weather-server',
},
]
app.tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=host_tools))
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
assert metadata['runner_config'] == ai_metadata
assert any(tool['name'] == 'event_reply' for tool in metadata['platform_tools'])
assert all(tool['name'] != 'call_platform_api' for tool in metadata['platform_tools'])
assert metadata['host_tools'] == host_tools
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
WORKSPACE_UUID,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
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,
},
{'name': 'event_processor', 'supported_event_patterns': ['*'], 'message_only': False},
]
class TestAgentServiceDebug:
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize(
'result_type',
[
'tool.call.started',
'tool.call.completed',
'message.delta',
'message.completed',
'processor.log',
'run.completed',
],
)
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self, streaming, result_type):
app = _make_app()
agent_config = _agent_row().config
agent_config['allowed_platform_tools'] = ['platform_get_user_info']
agent_config['event_tool_permissions'] = {
'message.*': ['event_reply'],
'group.member.joined': ['event_get_actor'],
}
agent_config['allowed_tools'] = ['exec', 'weather']
visible_event = {
'type': result_type,
'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}},
}
observer = AsyncMock() if streaming else None
async def run_runner(event, binding, adapter_context):
assert binding.delivery_policy.enable_streaming is streaming
await adapter_context['_result_observer']({**visible_event, 'private_context': 'must not leak'})
await adapter_context['_result_observer']({'type': 'state.updated', 'data': {'private': True}})
if streaming:
# Debug events reach the client before the runner returns its final output.
observer.assert_awaited_once_with(visible_event)
yield SimpleNamespace(
role='assistant',
content='debug result',
all_content=None,
)
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=run_runner))
service = AgentService(app)
service.get_agent = AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': AGENT_KIND_AGENT,
'supported_event_patterns': ['*'],
'config': agent_config,
}
)
context = SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid=WORKSPACE_UUID,
placement_generation=1,
principal=SimpleNamespace(account_uuid='account-test'),
entitlement_revision=0,
)
result = await service.debug_agent(
context,
'agent-1',
{
'event_type': 'group.member.joined',
'text': 'A member joined.',
'data': {'member_id': 'user-1'},
'conversation_id': 'debug-session',
},
on_result=observer,
)
if streaming:
observer.assert_awaited_once_with(visible_event)
assert result['execution_events'] == []
else:
assert result['execution_events'] == [visible_event]
assert result['event_type'] == 'group.member.joined'
assert result['conversation_id'] == 'debug-session'
assert result['final_text'] == 'debug result'
assert result['outputs'] == [
{
'kind': 'SimpleNamespace',
'role': 'assistant',
'text': 'debug result',
}
]
event, binding = app.agent_run_orchestrator.run.call_args.args
assert event.workspace_id == WORKSPACE_UUID
assert event.delivery.platform_capabilities['debug_mock'] is True
assert 'send_message' in event.delivery.platform_capabilities['supported_apis']
assert event.delivery.reply_target['target_id'] == 'debug-group'
assert binding.delivery_policy.enable_reply is False
assert event.data == {'member_id': 'user-1'}
assert binding.agent_id == 'agent-1'
assert binding.runner_id == 'plugin:test/runner/default'
assert binding.resource_policy.allowed_platform_tool_names == [
'platform_get_user_info',
'event_reply',
'event_get_actor',
'event_get_group',
'event_get_group_member',
]
assert binding.resource_policy.allow_all_tools is False
assert binding.resource_policy.allowed_tool_names == ['exec', 'weather']
assert (
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
== WORKSPACE_UUID
)
async def test_debug_agent_rejects_unsupported_event_type(self):
app = _make_app()
service = AgentService(app)
service.get_agent = AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': AGENT_KIND_AGENT,
'supported_event_patterns': ['message.*'],
'config': _agent_row().config,
}
)
context = SimpleNamespace(workspace_uuid=WORKSPACE_UUID)
with pytest.raises(ValueError, match='does not support'):
await service.debug_agent(
context,
'agent-1',
{'event_type': 'group.member.joined'},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'event_type',
[
'message.received',
'message.edited',
'message.deleted',
'message.reaction',
'group.member_joined',
'group.member_left',
'group.member_banned',
'friend.request_received',
'friend.added',
'feedback.received',
'bot.invited_to_group',
'bot.muted',
'bot.unmuted',
'bot.removed_from_group',
'platform.specific',
'custom.probe',
],
)
async def test_debug_event_matrix_preserves_scope_targets_and_mock_options(event_type):
app = _make_app()
captured = []
async def run(event, binding, adapter_context):
captured.append((event, binding))
if False:
yield
app.agent_run_orchestrator = SimpleNamespace(run=run)
service = AgentService(app)
service.get_agent = AsyncMock(
return_value={'kind': AGENT_KIND_AGENT, 'supported_event_patterns': ['*'], 'config': _agent_row().config}
)
context = SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid=WORKSPACE_UUID,
placement_generation=1,
principal=SimpleNamespace(account_uuid='account-test'),
entitlement_revision=0,
)
data = {
'group_id': '群组-42',
'member_id': 'user-42',
'member_name': '测试用户🙂',
'request_id': 'request-42',
'nested': {'value': [1, 2]},
}
await service.debug_agent(
context,
'agent-1',
{
'event_type': event_type,
'text': 'probe',
'data': data,
'mock': {'errors': {'event_reply': 'denied'}, 'unsupported_apis': ['delete_message']},
},
)
event, binding = captured[0]
assert event.data == data
assert event.actor.actor_name == '测试用户🙂'
assert event.delivery.reply_target['target_id'] == '群组-42'
assert 'delete_message' not in event.delivery.platform_capabilities['supported_apis']
assert event.delivery.platform_capabilities['mock_options']['errors'] == {'event_reply': 'denied'}
assert event.bot_id is None
assert binding.delivery_policy.enable_reply is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
'payload',
[
{'event_type': ''},
{'data': []},
{'data': None},
{'data': False},
{'mock': []},
{'mock': {'errors': {'event_reply': None}}},
{'actor': []},
],
)
async def test_debug_rejects_invalid_envelope_before_execution(payload):
app = _make_app()
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=AssertionError('invalid request executed')))
service = AgentService(app)
service.get_agent = AsyncMock(
return_value={'kind': AGENT_KIND_AGENT, 'supported_event_patterns': ['*'], 'config': _agent_row().config}
)
with pytest.raises(ValueError):
await service.debug_agent(SimpleNamespace(workspace_uuid=WORKSPACE_UUID), 'agent-1', payload)
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(
WORKSPACE_UUID,
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(WORKSPACE_UUID, '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(WORKSPACE_UUID, 'pipeline-1')
assert result['kind'] == AGENT_KIND_PIPELINE
assert 'enabled' not in result
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-team/LocalAgent/default',
usages=['agent'],
config_schema=[
{'name': 'model', 'default': 'gpt-4.1'},
{'name': 'temperature', 'default': 0.2},
{'name': 'no-default'},
],
)
app.runner_registry = SimpleNamespace(
list_runners=AsyncMock(return_value=[runner]), get=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(
WORKSPACE_UUID,
{
'name': 'Support Agent',
'description': 'Handles support events',
'emoji': 'S',
'component_ref': 'plugin:caller/must-not-win/default',
},
)
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 'enabled' not in insert_values
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)
@pytest.mark.parametrize(
'config',
[
None,
[],
{'runner': {'id': 'plugin:test/runner/default'}},
{'runner': {'id': 123}, 'runner_config': {}},
{
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {'plugin:test/runner/default': ['invalid']},
},
{
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {},
},
],
)
async def test_create_agent_rejects_malformed_4x_runner_config(self, config):
app = _make_app()
with pytest.raises(ValueError, match='Agent config|runner_config'):
await AgentService(app).create_agent(
WORKSPACE_UUID,
{'name': 'Invalid Agent', 'config': config},
)
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize(
('field_name', 'invalid_value'),
[
('enable-all-tools', 0),
('enable-all-tools', None),
('enable-all-tools', 'false'),
('mcp-resource-agent-read-enabled', 0),
('mcp-resource-agent-read-enabled', None),
('mcp-resource-agent-read-enabled', 'false'),
],
)
async def test_create_agent_rejects_non_boolean_security_fields_before_write(
self,
field_name,
invalid_value,
):
app = _make_app()
runner_id = 'plugin:test/runner/default'
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
await AgentService(app).create_agent(
WORKSPACE_UUID,
{
'name': 'Invalid Agent',
'config': {
'runner': {'id': runner_id},
'runner_config': {runner_id: {field_name: invalid_value}},
},
},
)
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_create_agent_rejects_non_boolean_mcp_resource_enabled_before_write(self, invalid_value):
app = _make_app()
runner_id = 'plugin:test/runner/default'
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
await AgentService(app).create_agent(
WORKSPACE_UUID,
{
'name': 'Invalid Agent',
'config': {
'runner': {'id': runner_id},
'runner_config': {
runner_id: {
'mcp-resources': [
{'uri': 'file:///README.md', 'enabled': invalid_value},
]
}
},
},
},
)
app.persistence_mgr.execute_async.assert_not_awaited()
async def test_create_agent_derives_empty_component_ref_from_empty_runner(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
await AgentService(app).create_agent(
WORKSPACE_UUID,
{
'name': 'Unconfigured Agent',
'component_ref': 'plugin:caller/must-not-win/default',
'config': {
'runner': {'id': ''},
'runner_config': {},
},
},
)
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert insert_values['component_ref'] is None
async def test_create_agent_preserves_explicit_empty_event_scope(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
await AgentService(app).create_agent(
WORKSPACE_UUID,
{
'name': 'Dormant Agent',
'supported_event_patterns': [],
'config': {
'runner': {'id': ''},
'runner_config': {},
},
},
)
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert insert_values['supported_event_patterns'] == []
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
with pytest.raises(ValueError, match='runner_config'):
await AgentService(app).update_agent(
WORKSPACE_UUID,
'agent-1',
{
'config': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {'plugin:test/runner/default': 'invalid'},
}
},
)
assert app.persistence_mgr.execute_async.await_count == 1
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(
WORKSPACE_UUID,
'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},
'component_ref': 'plugin:caller/must-not-win/default',
'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': [],
'component_ref': 'plugin:test/new-runner/default',
}
async def test_update_agent_ignores_component_ref_without_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_result(first_item=_agent_row(agent_uuid='agent-1')),
Mock(),
]
)
await AgentService(app).update_agent(
WORKSPACE_UUID,
'agent-1',
{
'name': 'Updated Agent',
'component_ref': 'plugin:caller/must-not-win/default',
},
)
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values == {
'name': 'Updated Agent',
'component_ref': 'plugin:test/runner/default',
}
async def test_update_agent_component_ref_only_repairs_from_existing_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_result(first_item=_agent_row(agent_uuid='agent-1')),
Mock(),
]
)
await AgentService(app).update_agent(
WORKSPACE_UUID,
'agent-1',
{'component_ref': 'plugin:caller/must-not-win/default'},
)
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values == {'component_ref': 'plugin:test/runner/default'}
async def test_update_agent_clears_component_ref_for_empty_runner(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_result(first_item=_agent_row(agent_uuid='agent-1')),
Mock(),
]
)
config = {'runner': {'id': ''}, 'runner_config': {}}
await AgentService(app).update_agent(
WORKSPACE_UUID,
'agent-1',
{
'component_ref': 'plugin:caller/must-not-win/default',
'config': config,
},
)
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values == {'config': config, 'component_ref': None}
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(
WORKSPACE_UUID,
{
'kind': AGENT_KIND_PIPELINE,
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
},
)
await service.update_agent(
WORKSPACE_UUID,
'pipeline-1',
{'name': 'Updated Pipeline'},
)
await service.delete_agent(WORKSPACE_UUID, 'pipeline-1')
assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE}
app.pipeline_service.create_pipeline.assert_awaited_once_with(
WORKSPACE_UUID,
{
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
},
)
app.pipeline_service.update_pipeline.assert_awaited_once_with(
WORKSPACE_UUID,
'pipeline-1',
{'name': 'Updated Pipeline'},
)
app.pipeline_service.delete_pipeline.assert_awaited_once_with(
WORKSPACE_UUID,
'pipeline-1',
)
async def test_event_processor_can_be_created_before_selecting_a_plugin():
app = _make_app()
service = AgentService(app)
result = await service.create_agent(
WORKSPACE_UUID,
{'kind': 'event_processor', 'name': 'Unconfigured', 'supported_event_patterns': ['*']},
)
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
assert result['kind'] == 'event_processor'
assert values['component_ref'] is None
assert values['supported_event_patterns'] == []
assert values['config'] == {}
async def test_event_processor_creation_uses_installed_component_scope():
app = _make_app()
ref = 'plugin:test/welcome/default'
descriptor = SimpleNamespace(
component_kind='Runner',
usages=['event'],
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
app.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_unconfigured_event_processor_can_select_a_plugin_after_creation():
app = _make_app()
row = _agent_row(config={})
row.kind = 'event_processor'
row.component_ref = None
ref = 'plugin:test/welcome/default'
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='Runner',
usages=['event'],
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
)
)
service = AgentService(app)
service._get_agent_row = AsyncMock(return_value=row)
await service.update_agent(
WORKSPACE_UUID,
row.uuid,
{'component_ref': ref, 'parameters': {'greeting': 'Hi'}, 'supported_event_patterns': ['*']},
)
values = _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])
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': 'invalid:a/b/c'})
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_not_awaited()
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='Runner',
usages=['event'],
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': 'plugin:a/b/c', 'parameters': {}}
)
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_awaited_once_with(WORKSPACE_UUID, 'plugin:a/b/c')
async def test_unavailable_event_processor_can_still_be_renamed():
app = _make_app()
row = _agent_row(config={'runner': {'id': 'plugin:a/b/c'}, 'runner_config': {'plugin:a/b/c': {}}})
row.kind = 'event_processor'
row.component_ref = 'plugin: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()
async def test_agent_runs_use_workspace_and_stable_agent_identity(monkeypatch):
service = AgentService(_make_app())
service.get_agent = AsyncMock(return_value={'kind': 'agent'})
service.ap.persistence_mgr.get_db_engine = Mock()
store = SimpleNamespace(list_runs=AsyncMock(return_value=([], None, False, 0)))
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
assert (await service.get_processor_runs(WORKSPACE_UUID, 'one', before_id=12))['total'] == 0
store.list_runs.assert_awaited_once_with(workspace_id=WORKSPACE_UUID, agent_id='one', before_id=12)
@pytest.mark.parametrize(
'binding,agent_id,workspace,allowed',
[
('agent_one_plugin:a/old/default', None, WORKSPACE_UUID, True),
('debug:one:plugin:a/new/default', None, WORKSPACE_UUID, True),
('any-new-binding', 'one', WORKSPACE_UUID, True),
('agent_two_plugin:a/old/default', None, WORKSPACE_UUID, False),
('debug:two:plugin:a/new/default', None, WORKSPACE_UUID, False),
('agent_one_plugin:a/old/default', 'two', WORKSPACE_UUID, False),
('agent_one_plugin:a/old/default', None, 'other-workspace', False),
('event_processor:one', None, WORKSPACE_UUID, False),
],
)
async def test_agent_trace_authorizes_history_across_runner_changes(monkeypatch, binding, agent_id, workspace, allowed):
service = AgentService(_make_app())
service.get_agent = AsyncMock(return_value={'kind': 'agent'})
service.ap.persistence_mgr.get_db_engine = Mock()
store = SimpleNamespace(
get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding, 'agent_id': agent_id}),
page_run_events=AsyncMock(return_value=([], None, None, False)),
)
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store))
if allowed:
await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run', after_sequence=100)
store.page_run_events.assert_awaited_once_with(run_id='run', after_sequence=100, limit=100)
else:
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()