mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
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]>
956 lines
36 KiB
Python
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()
|