Files
LangBot/tests/unit_tests/api/service/test_agent_service.py
T

770 lines
28 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,
)
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.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.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,
},
]
class TestAgentServiceDebug:
@pytest.mark.parametrize('streaming', [False, True])
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self, streaming):
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': 'tool.call.started',
'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}},
}
observer = AsyncMock() if streaming else None
async def run_agent(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}})
yield SimpleNamespace(
role='assistant',
content='debug result',
all_content=None,
)
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=run_agent))
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',
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(
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',
'config': {},
},
)
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',
)