feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
+5 -3
View File
@@ -1,4 +1,5 @@
"""Shared test fixtures for agent runner tests."""
from __future__ import annotations
import typing
@@ -45,6 +46,7 @@ def make_session(
available_apis: dict[str, bool] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
execution_query: typing.Any | None = None,
) -> dict[str, typing.Any]:
"""Create a minimal AgentRunSession dict for testing.
@@ -59,13 +61,12 @@ def make_session(
AgentRunSession dict with run-scoped authorization snapshot
"""
import time
now = int(time.time())
res = resources if resources is not None else make_resources()
apis = available_apis if available_apis is not None else {}
policy = (
state_policy
if state_policy is not None
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
state_policy if state_policy is not None else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
)
context = state_context if state_context is not None else {}
@@ -102,6 +103,7 @@ def make_session(
'run_id': run_id,
'runner_id': runner_id,
'query_id': query_id,
'execution_query': execution_query,
'plugin_identity': plugin_identity,
'authorization': {
'resources': res,
+46 -23
View File
@@ -8,6 +8,7 @@ Tests focus on:
Avoids circular imports by using proper import structure.
"""
from __future__ import annotations
import uuid
@@ -19,11 +20,12 @@ from langbot.pkg.agent.runner.errors import (
RunnerExecutionError,
RunnerNotAuthorizedError,
)
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# Define mock classes in dependency order (no forward references needed)
class MockLauncherType:
value = 'person'
@@ -59,6 +61,7 @@ class MockSession:
class MockQuery:
"""Mock Query for testing."""
def __init__(self):
self.query_id = 1
self.launcher_type = MockLauncherType()
@@ -93,6 +96,7 @@ class MockQuery:
class MockMessageChunk:
"""Mock MessageChunk for testing."""
def __init__(self, content, resp_message_id=None):
self.role = 'assistant'
self.content = content
@@ -106,6 +110,7 @@ class MockMessageChunk:
class MockEventContext:
"""Mock event context for testing."""
def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None):
self._prevented = prevented
self.event = MagicMock()
@@ -118,6 +123,7 @@ class MockEventContext:
class MockAgentRunOrchestrator:
"""Mock AgentRunOrchestrator for testing."""
def __init__(self, chunks=None, error=None):
self._chunks = chunks or []
self._error = error
@@ -138,6 +144,7 @@ class MockAgentRunOrchestrator:
class MockApplication:
"""Mock Application for testing."""
def __init__(self, orchestrator=None):
self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator()
self.logger = MagicMock()
@@ -226,11 +233,11 @@ class TestStreamingBehavior:
assert len(resp_messages) == 2
class TestConfigMigrationInChatHandler:
"""Tests for ConfigMigration usage in chat handler context."""
class TestRunnerConfigResolverInChatHandler:
"""Tests for RunnerConfigResolver usage in chat handler context."""
def test_resolve_runner_id_from_pipeline_config(self):
"""Chat handler should use ConfigMigration to resolve runner ID."""
"""Chat handler should use RunnerConfigResolver to resolve runner ID."""
pipeline_config = {
'ai': {
'runner': {
@@ -239,7 +246,7 @@ class TestConfigMigrationInChatHandler:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_old_runner_field_is_not_resolved(self):
@@ -252,7 +259,7 @@ class TestConfigMigrationInChatHandler:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id is None
@@ -302,19 +309,23 @@ class TestChatHandlerImports:
"""Import chat handler module should work."""
# This test verifies the import works without circular dependency
from langbot.pkg.pipeline.process.handlers import chat
assert chat.ChatMessageHandler is not None
def test_chat_handler_class_exists(self):
"""ChatMessageHandler class should be defined."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert ChatMessageHandler.__name__ == 'ChatMessageHandler'
def test_chat_handler_has_handle_method(self):
"""ChatMessageHandler should have async generator handle method."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert hasattr(ChatMessageHandler, 'handle')
# handle returns AsyncGenerator, so check for async generator function
import inspect
assert inspect.isasyncgenfunction(ChatMessageHandler.handle)
@@ -353,8 +364,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -396,8 +409,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -439,8 +454,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -460,9 +477,7 @@ class TestChatHandlerAsyncBehavior:
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerNotFoundError('plugin:notexist/unknown/default')
)
orchestrator = MockAgentRunOrchestrator(error=RunnerNotFoundError('plugin:notexist/unknown/default'))
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
@@ -479,8 +494,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -518,8 +535,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -556,8 +575,10 @@ class TestChatHandlerAsyncBehavior:
user_notice=kwargs.get('user_notice'),
)
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -593,8 +614,10 @@ class TestChatHandlerAsyncBehavior:
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
with (
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
@@ -1,113 +0,0 @@
"""Tests for current AgentRunner config helpers."""
from __future__ import annotations
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestResolveRunnerId:
"""Tests for ConfigMigration.resolve_runner_id."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_does_not_resolve_legacy_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None
def test_resolve_no_runner_config(self):
runner_id = ConfigMigration.resolve_runner_id({})
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for ConfigMigration.resolve_runner_config."""
def test_resolve_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_does_not_read_legacy_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
def test_resolve_no_config(self):
config = ConfigMigration.resolve_runner_config(
{},
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
class TestGetExpireTime:
"""Tests for ConfigMigration.get_expire_time."""
def test_get_expire_time_zero(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 0,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 0
def test_get_expire_time_positive(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 3600,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 3600
def test_get_expire_time_default(self):
expire_time = ConfigMigration.get_expire_time({})
assert expire_time == 0
@@ -0,0 +1,207 @@
"""Tests for current AgentRunner config resolution."""
from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
class TestResolveRunnerId:
"""Tests for RunnerConfigResolver.resolve_runner_id."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_does_not_resolve_legacy_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id is None
def test_resolve_no_runner_config(self):
runner_id = RunnerConfigResolver.resolve_runner_id({})
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for RunnerConfigResolver.resolve_runner_config."""
def test_resolve_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
},
},
}
config = RunnerConfigResolver.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_does_not_read_legacy_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
},
},
}
config = RunnerConfigResolver.resolve_runner_config(
pipeline_config,
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
def test_resolve_no_config(self):
config = RunnerConfigResolver.resolve_runner_config(
{},
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
class TestGetExpireTime:
"""Tests for RunnerConfigResolver.get_expire_time."""
def test_get_expire_time_zero(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 0,
},
},
}
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
assert expire_time == 0
def test_get_expire_time_positive(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 3600,
},
},
}
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
assert expire_time == 3600
class TestValidateCurrentConfig:
@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'),
],
)
def test_agent_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
config = {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
field_name: invalid_value,
}
},
}
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
RunnerConfigResolver.validate_agent_config(config)
@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'),
],
)
def test_pipeline_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
config = {
'ai': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
field_name: invalid_value,
}
},
}
}
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
RunnerConfigResolver.validate_pipeline_config(config)
def test_only_selected_runner_security_fields_are_validated(self):
config = {
'runner': {'id': 'plugin:test/selected/default'},
'runner_config': {
'plugin:test/selected/default': {'enable-all-tools': False},
'plugin:test/unselected/default': {'enable-all-tools': 'preserved'},
},
}
assert RunnerConfigResolver.validate_agent_config(config) is config
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
@pytest.mark.parametrize('config_kind', ['agent', 'pipeline'])
def test_rejects_non_boolean_mcp_resource_enabled(self, config_kind, invalid_value):
runner_id = 'plugin:test/runner/default'
runner_config = {'mcp-resources': [{'uri': 'file:///README.md', 'enabled': invalid_value}]}
if config_kind == 'agent':
config = {
'runner': {'id': runner_id},
'runner_config': {runner_id: runner_config},
}
validate = RunnerConfigResolver.validate_agent_config
else:
config = {
'ai': {
'runner': {'id': runner_id},
'runner_config': {runner_id: runner_config},
}
}
validate = RunnerConfigResolver.validate_pipeline_config
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
validate(config)
@pytest.mark.parametrize('enabled', [True, False])
def test_accepts_boolean_mcp_resource_enabled(self, enabled):
runner_config = {'mcp-resources': [{'enabled': enabled}]}
assert RunnerConfigResolver.validate_runner_security_fields(runner_config) is runner_config
def test_get_expire_time_default(self):
expire_time = RunnerConfigResolver.get_expire_time({})
assert expire_time == 0
@@ -1,10 +1,10 @@
"""Tests for persisted AgentRunner config shape."""
"""Tests for persisted AgentRunner config templates."""
from __future__ import annotations
import json
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
class TestDefaultPipelineConfig:
@@ -35,7 +35,7 @@ class TestResolveRunnerId:
'runner': {'id': 'plugin:test/my-runner/default'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
runner_id = RunnerConfigResolver.resolve_runner_id(config)
assert runner_id == 'plugin:test/my-runner/default'
def test_old_runner_field_is_not_supported(self):
@@ -44,7 +44,7 @@ class TestResolveRunnerId:
'runner': {'runner': 'local-agent'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
runner_id = RunnerConfigResolver.resolve_runner_id(config)
assert runner_id is None
@@ -59,7 +59,7 @@ class TestResolveRunnerConfig:
},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config['custom-option'] == 20
def test_old_runner_block_is_not_supported(self):
@@ -68,5 +68,5 @@ class TestResolveRunnerConfig:
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config == {}
@@ -0,0 +1,225 @@
"""Tests for Host-only AgentRunner tool execution context."""
from __future__ import annotations
import json
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
from langbot.pkg.agent.runner.execution_context import (
append_mcp_resource_context_to_event,
build_execution_query,
build_host_box_scope,
prepare_box_scope,
prepare_execution_query,
project_mcp_resource_config,
)
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.utils import constants
class PlatformAdapter:
pass
def make_event(
*,
event_id: str = 'event-1',
conversation_id: str | None = 'person_user-1',
target_type: str | None = 'person',
target_id: str | None = 'user-1',
adapter: str = 'PlatformAdapter',
) -> AgentEventEnvelope:
reply_target = {}
if target_type is not None:
reply_target['target_type'] = target_type
if target_id is not None:
reply_target['target_id'] = target_id
return AgentEventEnvelope(
event_id=event_id,
event_type='message.received',
source='platform',
bot_id='bot-1',
workspace_id='workspace-1',
conversation_id=conversation_id,
input=AgentInput(text='hello'),
delivery=DeliveryContext(
surface='platform',
reply_target=reply_target,
platform_capabilities={'adapter': adapter},
),
)
def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch):
monkeypatch.setattr(constants, 'instance_id', 'instance-1')
event = make_event()
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
sender_id='user-1',
adapter=PlatformAdapter(),
variables={},
)
prepare_execution_query(query, event, ['pdf'])
event_query = build_execution_query(event, ['pdf'])
assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope']
assert query.variables['_pipeline_bound_skills'] == ['pdf']
assert event_query.variables['_pipeline_bound_skills'] == ['pdf']
scope = json.loads(query.variables['_host_box_scope'])
assert scope == {
'instance_id': 'instance-1',
'workspace_id': 'workspace-1',
'bot_id': 'bot-1',
'platform_adapter': 'PlatformAdapter',
'target_type': 'person',
'target_id': 'user-1',
'thread_id': None,
}
def test_prepare_box_scope_does_not_change_existing_skill_projection():
event = make_event()
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
variables={'_pipeline_bound_skills': ['existing']},
)
variables = prepare_box_scope(query, event)
assert variables['_host_box_scope']
assert variables['_pipeline_bound_skills'] == ['existing']
def test_prepare_box_scope_preserves_event_first_channel_scope():
channel_event = make_event(target_type='channel', target_id='same')
channel_query = build_execution_query(channel_event, [])
original_scope = channel_query.variables['_host_box_scope']
prepare_execution_query(channel_query, channel_event, [])
person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[
'_host_box_scope'
]
assert channel_query.variables['_host_box_scope'] == original_scope
assert json.loads(original_scope)['target_type'] == 'channel'
assert original_scope != person_scope
def test_prepare_box_scope_overwrites_untrusted_existing_scope():
event = make_event(target_type='person', target_id='user-1')
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='user-1',
variables={'_host_box_scope': 'forged-scope'},
)
variables = prepare_box_scope(query, event)
assert variables['_host_box_scope'] != 'forged-scope'
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
query = pipeline_query.Query.model_construct(variables={})
attachments = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
variables = project_mcp_resource_config(
query,
{
'mcp-resources': attachments,
'mcp-resource-agent-read-enabled': False,
},
)
assert variables['_pipeline_mcp_resource_attachments'] == attachments
assert variables['_pipeline_mcp_resource_attachments'] is not attachments
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
def test_project_mcp_resource_config_fails_closed_for_non_boolean_read_flag():
query = pipeline_query.Query.model_construct(variables={})
variables = project_mcp_resource_config(
query,
{'mcp-resource-agent-read-enabled': 0},
)
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
def test_pinned_context_updates_text_and_structured_input():
event = make_event()
event.input = AgentInput(
text='hello',
contents=[ContentElement.from_text('hello')],
)
append_mcp_resource_context_to_event(event, '\n\npinned-context')
assert event.input.text == 'hello\n\npinned-context'
assert event.input.contents[0].text == 'hello\n\npinned-context'
def test_event_reply_target_populates_valid_session_identity():
event = make_event(conversation_id='rotating-transcript-id', target_type='group', target_id='room-1')
query = build_execution_query(event, [])
assert query.launcher_type.value == 'group'
assert query.launcher_id == 'room-1'
assert query.sender_id == 'room-1'
assert query.session.launcher_type.value == 'group'
assert query.session.launcher_id == 'room-1'
scope = json.loads(query.variables['_host_box_scope'])
assert scope['target_type'] == 'group'
assert scope['target_id'] == 'room-1'
assert 'rotating-transcript-id' not in query.variables['_host_box_scope']
def test_non_message_event_without_conversation_uses_event_scope():
event = make_event(
event_id='scheduled/event/中文',
conversation_id=None,
target_type=None,
target_id=None,
adapter='scheduler',
)
event.event_type = 'schedule.triggered'
query = build_execution_query(event, [])
scope = json.loads(query.variables['_host_box_scope'])
assert scope['target_type'] == 'event'
assert scope['target_id'] == event.event_id
assert query.pipeline_config is None
assert query.pipeline_uuid is None
def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch):
event = make_event(adapter='AdapterA')
monkeypatch.setattr(constants, 'instance_id', 'instance-a')
first = build_host_box_scope(event)
monkeypatch.setattr(constants, 'instance_id', 'instance-b')
second = build_host_box_scope(event)
other_adapter = build_host_box_scope(make_event(adapter='AdapterB'))
assert first != second
assert second != other_adapter
+16 -16
View File
@@ -421,7 +421,7 @@ class TestRetrieveKnowledgeBaseAuthorization:
# When no run_id, the handler checks against pipeline's configured KBs
# This is the unscoped path for regular plugin calls
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# Simulate pipeline config
pipeline_config = {
@@ -437,10 +437,10 @@ class TestRetrieveKnowledgeBaseAuthorization:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:test/runner/default'
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_001' in allowed_kbs
assert 'kb_999' not in allowed_kbs
@@ -534,12 +534,12 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
without first resolving the runner_id, causing issues when non-local-agent runners
were used.
Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config.
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
"""
def test_retrieve_kb_fix_local_agent_runner(self):
"""Fix should work for local-agent runner."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -554,15 +554,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_001' in allowed_kbs
def test_retrieve_kb_fix_other_runner(self):
"""Fix should work for non-local-agent runners."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -577,15 +577,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kbs = runner_config.get('knowledge-bases', [])
assert 'kb_custom' in allowed_kbs
def test_retrieve_kb_does_not_read_old_runner_format(self):
"""The 4.x authorization path ignores legacy Pipeline runner fields."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
pipeline_config = {
'ai': {
@@ -598,8 +598,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
assert runner_id is None
assert runner_config == {}
@@ -922,7 +922,7 @@ class TestNoRunIdBackwardCompatPath:
@pytest.mark.asyncio
async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
"""RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
# When no run_id, handler.py lines 897-914 check pipeline config
pipeline_config = {
@@ -938,8 +938,8 @@ class TestNoRunIdBackwardCompatPath:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
allowed_kb_uuids = runner_config.get('knowledge-bases', [])
# kb_001 should be allowed
@@ -234,6 +234,12 @@ def make_query():
'_pipeline_bound_plugins': ['langbot-team/LocalAgent'],
'_fallback_model_uuids': ['model_fallback'],
'_pipeline_bound_skills': ['demo'],
'_host_tool_source_refs': {
'langbot/test-tool/search': {
'source': 'plugin',
'source_id': 'langbot/test-tool',
},
},
'public_param': 'visible',
},
use_llm_model_uuid='model_primary',
@@ -704,6 +710,98 @@ async def test_orchestrator_unregisters_session_after_event_log_failure(clean_ag
assert await get_session_registry().list_active_runs() == []
@pytest.mark.asyncio
async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_agent_state):
"""Dropped steering audits retain routing metadata but never execution-only context."""
from langbot.pkg.agent.runner.event_log_store import EventLogStore
class BlockingPluginConnector(FakePluginConnector):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'runner_name': runner_name,
}
)
self.contexts.append(context)
self.sessions_during_run.append(await get_session_registry().get(context['run_id']))
self.started.set()
await self.release.wait()
yield {
'type': 'message.completed',
'data': {'message': {'role': 'assistant', 'content': 'response'}},
}
db_engine = clean_agent_state
descriptor = make_descriptor()
descriptor.capabilities.steering = True
plugin_connector = BlockingPluginConnector()
ap = FakeApplication(plugin_connector, db_engine)
pinned_context = 'PINNED_CONTEXT_MUST_NOT_BE_PERSISTED'
async def build_resource_context(query):
attachments = query.variables.get('_pipeline_mcp_resource_attachments', [])
return pinned_context if attachments else ''
mcp_loader = types.SimpleNamespace(build_resource_context_for_query=AsyncMock(side_effect=build_resource_context))
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
active_query = make_query()
async def consume_active_run():
return [message async for message in orchestrator.run_from_query(active_query)]
active_task = asyncio.create_task(consume_active_run())
await asyncio.wait_for(plugin_connector.started.wait(), timeout=1)
steering_query = make_query()
steering_query.query_id = 1002
steering_query.message_chain[0].id = 'msg_002'
steering_query.variables['_pipeline_mcp_resource_attachments'] = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
steering_query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
try:
claimed = await orchestrator.try_claim_steering_from_query(steering_query)
finally:
plugin_connector.release.set()
messages = await asyncio.wait_for(active_task, timeout=1)
assert claimed is True
assert len(messages) == 1
event_store = EventLogStore(db_engine)
event_logs, _, _ = await event_store.page_events(
conversation_id=steering_query.session.using_conversation.uuid,
limit=10,
)
dropped = next(event for event in event_logs if event['event_type'] == 'steering.dropped')
queued = next(
event
for event in event_logs
if event['event_type'] == 'message.received'
and event.get('metadata', {}).get('steering', {}).get('status') == 'queued'
)
assert dropped['input_summary'] == 'Unconsumed steering input dropped'
assert dropped['input_json'] is None
assert dropped['metadata']['steering']['original_event_id'] == queued['event_id']
assert pinned_context not in str(event_logs)
@pytest.mark.asyncio
async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
"""Test that orchestrator enforces total runner timeout."""
@@ -733,6 +831,48 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
class TestQueryEntrySessionQueryId:
"""Tests for internal query_id entering session registry."""
@pytest.mark.asyncio
async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state):
"""Inbound staging and later runner tools resolve to the same Box session."""
from langbot.pkg.box.service import BoxService
class CapturingBoxService:
available = True
def __init__(self):
self.resolver = object.__new__(BoxService)
self.materialize_session_id = None
async def materialize_inbound_attachments(self, query):
self.materialize_session_id = self.resolver.resolve_box_session_id(query)
return []
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
'type': 'message.completed',
'data': {'message': {'role': 'assistant', 'content': 'response'}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
box_service = CapturingBoxService()
ap.box_service = box_service
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
session = plugin_connector.sessions_during_run[0]
assert session is not None
assert session['execution_query'] is query
runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query'])
assert box_service.materialize_session_id == runner_session_id
assert query.variables['_host_box_scope']
@pytest.mark.asyncio
async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
"""query_id from Query entry flow is registered internally in session."""
@@ -765,6 +905,9 @@ class TestQueryEntrySessionQueryId:
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run['query_id'] == query.query_id
assert session_during_run['execution_query'] is query
assert query.pipeline_uuid == 'pipeline_001'
assert query.pipeline_config is not None
@pytest.mark.asyncio
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
@@ -791,6 +934,10 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
# Create event and binding directly (not from Query)
@@ -805,7 +952,11 @@ class TestQueryEntrySessionQueryId:
thread_id=None,
actor=None,
subject=None,
input=AgentInput(text='hello', contents=[], attachments=[]),
input=AgentInput(
text='hello',
contents=[provider_message.ContentElement.from_text('hello')],
attachments=[],
),
delivery=DeliveryContext(surface='test', supports_streaming=True),
)
binding = AgentBinding(
@@ -813,7 +964,17 @@ class TestQueryEntrySessionQueryId:
scope=BindingScope(scope_type='agent', scope_id='pipeline_001'),
event_types=['message.received'],
runner_id=RUNNER_ID,
runner_config={},
runner_config={
'mcp-resources': [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
],
'mcp-resource-agent-read-enabled': True,
},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
@@ -827,6 +988,26 @@ class TestQueryEntrySessionQueryId:
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run['query_id'] is None
execution_query = session_during_run['execution_query']
assert execution_query is not None
assert execution_query.pipeline_uuid is None
assert execution_query.pipeline_config is None
assert execution_query.bot_uuid == event.bot_id
assert execution_query.launcher_id == event.conversation_id
assert execution_query.sender_id == event.conversation_id
assert execution_query.session.launcher_id == event.conversation_id
assert execution_query.message_event.type == event.event_type
assert execution_query.variables['_host_box_scope']
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text']
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello'
assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
class TestQueryEntryAdapterParams:
@@ -1106,8 +1287,21 @@ class TestQueryEntryAdapterHostCapabilities:
]
)
ap = FakeApplication(plugin_connector, db_engine)
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables['_pipeline_mcp_resource_attachments'] = [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
}
]
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
query.user_message = provider_message.Message(
role='user',
content=[
@@ -1119,6 +1313,7 @@ class TestQueryEntryAdapterHostCapabilities:
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
# Check EventLog has incoming event
event_log_store = EventLogStore(db_engine)
@@ -1132,6 +1327,7 @@ class TestQueryEntryAdapterHostCapabilities:
assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None
assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True
assert 'aGVsbG8=' not in str(event_logs[0]['input_json'])
assert 'Pinned documentation' not in str(event_logs[0]['input_json'])
# Check Transcript has user and assistant messages
transcript_store = TranscriptStore(db_engine)
@@ -1149,3 +1345,4 @@ class TestQueryEntryAdapterHostCapabilities:
assert user_item['content_json']['content'][1]['image_base64'] is None
assert user_item['attachment_refs'][0]['content'] is None
assert 'aGVsbG8=' not in str(user_item)
assert 'Pinned documentation' not in str(user_item)
+144 -24
View File
@@ -1,4 +1,5 @@
"""Tests for AgentResourceBuilder."""
from __future__ import annotations
from types import SimpleNamespace
@@ -10,6 +11,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
RUNNER_ID = 'plugin:test/runner/default'
@@ -100,6 +102,7 @@ def app():
mock_app.skill_mgr = None
mock_app.tool_mgr = Mock()
mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None))
mock_app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
return mock_app
@@ -130,11 +133,13 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
],
)
query = make_query({
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
'aux-model': 'aux',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
'aux-model': 'aux',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -173,10 +178,12 @@ async def test_build_models_from_config_without_manifest_acl(app):
],
permissions={},
)
query = make_query({
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
'rerank-model': 'rerank',
})
query = make_query(
{
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -196,10 +203,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
],
)
query = make_query({
'model': 'llm',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': 'llm',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -234,10 +243,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
{'name': 'knowledge-bases', 'type': 'select-knowledge-bases'},
],
)
query = make_query({
'model': 'llm_alias',
'knowledge-bases': ['kb_alias'],
})
query = make_query(
{
'model': 'llm_alias',
'knowledge-bases': ['kb_alias'],
}
)
resources = await build_resources(app, query, descriptor)
@@ -271,10 +282,12 @@ async def test_build_models_manifest_permission_narrows_binding(app):
'models': ['rerank'],
},
)
query = make_query({
'model': 'llm',
'rerank-model': 'rerank',
})
query = make_query(
{
'model': 'llm',
'rerank-model': 'rerank',
}
)
resources = await build_resources(app, query, descriptor)
@@ -309,7 +322,7 @@ async def test_build_tools_authorizes_query_declared_tools(app):
"""Tools discovered by Pipeline preprocessing become run-scoped authorized
resources, with full parameters schema prefilled by the host."""
app.tool_mgr.get_tool_schema = AsyncMock(
side_effect=lambda name: {
side_effect=lambda name, source_ref=None: {
'qa_plugin_echo': (
'Echo test tool',
{'type': 'object', 'properties': {'text': {'type': 'string'}}},
@@ -321,6 +334,12 @@ async def test_build_tools_authorizes_query_declared_tools(app):
)
query = make_query(
{},
variables={
'_host_tool_source_refs': {
'qa_plugin_echo': {'source': 'plugin', 'source_id': 'test/plugin'},
'qa_mcp_echo': {'source': 'mcp', 'source_id': 'mcp-server'},
},
},
use_funcs=[
{'name': 'qa_plugin_echo', 'description': 'Echo test tool'},
SimpleNamespace(name='qa_mcp_echo'),
@@ -332,17 +351,21 @@ async def test_build_tools_authorizes_query_declared_tools(app):
assert resources['tools'] == [
{
'tool_name': 'qa_plugin_echo',
'tool_type': None,
'tool_type': 'plugin',
'description': 'Echo test tool',
'operations': ['detail', 'call'],
'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}},
'source': 'plugin',
'source_id': 'test/plugin',
},
{
'tool_name': 'qa_mcp_echo',
'tool_type': None,
'tool_type': 'mcp',
'description': None,
'operations': ['detail', 'call'],
'parameters': None,
'source': 'mcp',
'source_id': 'mcp-server',
},
]
@@ -369,6 +392,103 @@ async def test_build_tools_manifest_permission_denies_binding_tools(app):
assert resources['tools'] == []
@pytest.mark.asyncio
async def test_build_tools_materializes_independent_agent_all_tools_policy(app):
"""Independent Agents resolve an all-tools grant against the live Host catalog."""
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{'name': 'exec', 'source': 'builtin'},
{'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool']
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@pytest.mark.asyncio
async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(app):
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{'name': 'exec', 'source': 'builtin'},
{'name': 'langbot_mcp_list_resources', 'source': 'mcp'},
{'name': 'langbot_mcp_read_resource', 'source': 'mcp'},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={'mcp-resource-agent-read-enabled': False},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert [tool['tool_name'] for tool in resources['tools']] == ['exec']
@pytest.mark.asyncio
async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads_disabled(app):
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{
'name': 'langbot_mcp_read_resource',
'source': 'plugin',
'source_id': 'test/resource-reader',
},
]
)
descriptor = make_descriptor(capabilities={'tool_calling': True})
binding = AgentBinding(
binding_id='agent-binding',
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
runner_id=RUNNER_ID,
runner_config={'mcp-resource-agent-read-enabled': False},
resource_policy=ResourcePolicy(allow_all_tools=True),
)
resources = await AgentResourceBuilder(app).build_resources_from_binding(
event=QueryEntryAdapter.query_to_event(make_query({})),
binding=binding,
descriptor=descriptor,
)
assert resources['tools'] == [
{
'tool_name': 'langbot_mcp_read_resource',
'tool_type': 'plugin',
'description': None,
'operations': ['detail', 'call'],
'parameters': None,
'source': 'plugin',
'source_id': 'test/resource-reader',
}
]
@pytest.mark.asyncio
async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
descriptor = make_descriptor(
@@ -0,0 +1,91 @@
"""Tests for generic AgentRunner resource-policy projection."""
from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.resource_policy import ResourcePolicyProjector
def test_pipeline_projection_intersects_selected_tools_with_scoped_tools():
policy = ResourcePolicyProjector.from_runner_config(
{
'enable-all-tools': False,
'tools': ['plugin_tool', 'missing_tool', 'plugin_tool'],
'knowledge-bases': ['kb-config'],
},
resolved_model_uuids=['model-1', 'model-1'],
resolved_tool_names=['exec', 'plugin_tool', 'mcp_tool'],
resolved_kb_uuids=['kb-runtime'],
resolved_skill_names=[],
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['plugin_tool']
assert policy.allowed_model_uuids == ['model-1']
assert policy.allowed_kb_uuids == ['kb-runtime']
assert policy.allowed_skill_names == []
def test_pipeline_projection_materializes_enable_all_tools_from_scoped_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': True, 'tools': ['ignored']},
resolved_tool_names=['exec', 'mcp_tool'],
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['mcp_tool']},
resolved_tool_names=['plugin_tool', 'mcp_tool'],
resolved_tool_sources={
'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'},
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
},
)
assert policy.allowed_tool_sources == {
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
}
def test_independent_agent_projection_preserves_all_tools_intent():
policy = ResourcePolicyProjector.from_runner_config({})
assert policy.allow_all_tools is True
assert policy.allowed_tool_names is None
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
def test_enable_all_tools_fails_closed_for_non_boolean_values(invalid_value):
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': invalid_value, 'tools': ['explicit-tool']},
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['explicit-tool']
def test_independent_agent_projection_preserves_selected_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['exec', None, 'exec', 123]},
)
assert policy.allow_all_tools is False
assert policy.allowed_tool_names == ['exec']
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
policy = ResourcePolicyProjector.from_runner_config(
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
)
tools = [
{'name': 'dict-tool'},
SimpleNamespace(name='object-tool'),
SimpleNamespace(name='other-tool'),
]
assert ResourcePolicyProjector.filter_tools(tools, policy) == tools[:2]
+53 -21
View File
@@ -1,4 +1,5 @@
"""Tests for AgentRunSessionRegistry."""
from __future__ import annotations
import pytest
@@ -49,6 +50,27 @@ class TestSessionRegistryBasic:
assert 'permissions' not in result
assert '_authorized_ids' not in result
@pytest.mark.asyncio
async def test_register_keeps_host_execution_query_by_identity(self):
"""The runtime registry keeps the Host Query view in memory only."""
registry = AgentRunSessionRegistry()
execution_query = object()
await registry.register(
run_id='run_execution_query',
runner_id='plugin:test/my-runner/default',
query_id=None,
plugin_identity='test/my-runner',
resources=make_resources(),
execution_query=execution_query,
)
session = await registry.get('run_execution_query')
assert session is not None
assert session['query_id'] is None
assert session['execution_query'] is execution_query
@pytest.mark.asyncio
async def test_register_requires_plugin_identity(self):
"""Agent run sessions must always have an owning plugin identity."""
@@ -319,27 +341,36 @@ class TestSessionRegistryBasic:
available_apis={'steering_pull': True},
)
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
) == 'run_steering_scoped'
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_2',
workspace_id='workspace_1',
thread_id='thread_1',
) is None
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_2',
) is None
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
)
== 'run_steering_scoped'
)
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_2',
workspace_id='workspace_1',
thread_id='thread_1',
)
is None
)
assert (
await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_2',
)
is None
)
@pytest.mark.asyncio
async def test_unregister_returns_pending_steering_queue(self):
@@ -554,6 +585,7 @@ class TestGlobalRegistry:
# in tests can cause UnboundLocalError due to Python scoping
# Instead, just verify the function signature
from langbot.pkg.agent.runner.session_registry import get_session_registry
assert callable(get_session_registry)
# Create a fresh instance directly to verify the class works
@@ -39,7 +39,8 @@ def _agent_row(
emoji='A',
kind=AGENT_KIND_AGENT,
component_ref='plugin:test/runner/default',
config=config or {
config=config
or {
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
},
@@ -71,11 +72,7 @@ def _compiled_params(statement):
def _compiled_update_values(statement):
return {
key: value
for key, value in statement.compile().params.items()
if not key.startswith('uuid_')
}
return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')}
def _make_app():
@@ -224,6 +221,7 @@ class TestAgentServiceCreateUpdateDelete:
'name': 'Support Agent',
'description': 'Handles support events',
'emoji': 'S',
'component_ref': 'plugin:caller/must-not-win/default',
}
)
@@ -240,6 +238,122 @@ class TestAgentServiceCreateUpdateDelete:
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({'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(
{
'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(
{
'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(
{
'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_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(
'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(
@@ -261,6 +375,7 @@ class TestAgentServiceCreateUpdateDelete:
'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': [],
@@ -275,6 +390,67 @@ class TestAgentServiceCreateUpdateDelete:
'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(
'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(
'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(
'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))
@@ -231,6 +231,63 @@ class TestPipelineServiceCreatePipeline:
with pytest.raises(ValueError, match='Maximum number of pipelines'):
await service.create_pipeline({'name': 'New Pipeline'})
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
async def test_create_pipeline_rejects_non_object_extension_preferences(
self,
invalid_preferences,
):
service = PipelineService(SimpleNamespace())
with pytest.raises(ValueError, match='extensions_preferences must be an object'):
await service.create_pipeline(
{
'name': 'Invalid Pipeline',
'extensions_preferences': invalid_preferences,
}
)
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_create_pipeline_rejects_non_boolean_runner_security_field(self, invalid_value):
service = PipelineService(SimpleNamespace())
runner_id = 'plugin:test/runner/default'
with pytest.raises(ValueError, match='enable-all-tools.*boolean'):
await service.create_pipeline(
{
'name': 'Invalid Pipeline',
'config': {
'ai': {
'runner': {'id': runner_id},
'runner_config': {runner_id: {'enable-all-tools': invalid_value}},
}
},
}
)
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_create_pipeline_rejects_non_boolean_mcp_resource_enabled(self, invalid_value):
service = PipelineService(SimpleNamespace())
runner_id = 'plugin:test/runner/default'
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
await service.create_pipeline(
{
'name': 'Invalid Pipeline',
'config': {
'ai': {
'runner': {'id': runner_id},
'runner_config': {
runner_id: {
'mcp-resources': [
{'uri': 'file:///README.md', 'enabled': invalid_value},
]
}
},
}
},
}
)
async def test_create_pipeline_no_limit(self):
"""Creates pipeline without limit when max_pipelines=-1."""
# Setup
@@ -387,6 +444,43 @@ class TestPipelineServiceUpdatePipeline:
assert ['should-be-removed'] not in update_params.values()
assert not any(value is True for value in update_params.values())
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
async def test_update_pipeline_rejects_non_object_extension_preferences_before_write(
self,
invalid_preferences,
):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
with pytest.raises(ValueError, match='extensions_preferences must be an object'):
await service.update_pipeline(
'test-uuid',
{'extensions_preferences': invalid_preferences},
)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_update_pipeline_rejects_non_boolean_runner_security_field_before_write(self, invalid_value):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
runner_id = 'plugin:test/runner/default'
with pytest.raises(ValueError, match='mcp-resource-agent-read-enabled.*boolean'):
await service.update_pipeline(
'test-uuid',
{
'config': {
'ai': {
'runner': {'id': runner_id},
'runner_config': {runner_id: {'mcp-resource-agent-read-enabled': invalid_value}},
}
}
},
)
ap.persistence_mgr.execute_async.assert_not_awaited()
async def test_update_pipeline_name_does_not_rewrite_bot_routes(self):
"""Bot event bindings remain independent from pipeline display names."""
# Setup
@@ -624,6 +718,98 @@ class TestPipelineServiceUpdatePipelineExtensions:
with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'):
await service.update_pipeline_extensions('nonexistent-uuid', [])
@pytest.mark.parametrize(
('field', 'invalid_value'),
[
('bound_plugins', 'author/plugin'),
('bound_plugins', [{'author': 'author'}]),
('bound_mcp_servers', 'server-1'),
('bound_mcp_servers', ['server-1', 2]),
('bound_skills', 'skill-1'),
('bound_skills', ['skill-1', None]),
('bound_mcp_resources', {'uri': 'file:///README.md'}),
('bound_mcp_resources', [{'uri': 'file:///README.md'}, 'bad']),
],
)
async def test_update_extensions_rejects_malformed_binding_lists_before_query(
self,
field,
invalid_value,
):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
kwargs = {field: invalid_value}
if field != 'bound_plugins':
kwargs['bound_plugins'] = []
with pytest.raises(ValueError, match=field):
await service.update_pipeline_extensions('test-uuid', **kwargs)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize('invalid_value', [0, 'false'])
async def test_update_extensions_rejects_non_boolean_resource_read_before_query(self, invalid_value):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
with pytest.raises(ValueError, match='mcp_resource_agent_read_enabled.*boolean'):
await service.update_pipeline_extensions(
'test-uuid',
[],
mcp_resource_agent_read_enabled=invalid_value,
)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize(
'field',
[
'enable_all_plugins',
'enable_all_mcp_servers',
'enable_all_skills',
],
)
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_update_extensions_rejects_non_boolean_enable_all_flags_before_query(
self,
field,
invalid_value,
):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
with pytest.raises(ValueError, match=rf'{field}.*boolean'):
await service.update_pipeline_extensions(
'test-uuid',
[],
**{field: invalid_value},
)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_update_extensions_rejects_non_boolean_attachment_enabled_before_query(
self,
invalid_value,
):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
service = PipelineService(ap)
with pytest.raises(ValueError, match=r'bound_mcp_resources.*enabled.*boolean'):
await service.update_pipeline_extensions(
'test-uuid',
[],
bound_mcp_resources=[
{
'server_uuid': 'server-1',
'uri': 'file:///README.md',
'enabled': invalid_value,
}
],
)
ap.persistence_mgr.execute_async.assert_not_awaited()
async def test_update_extensions_sets_plugins(self):
"""Updates plugins in extensions_preferences."""
# Setup
@@ -667,7 +853,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Execute
bound_plugins = [{'plugin_uuid': 'plugin-1'}]
bound_plugins = [{'author': 'test', 'name': 'plugin-1'}]
await service.update_pipeline_extensions(
'test-uuid',
bound_plugins=bound_plugins,
@@ -0,0 +1,67 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(agent_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(agent_service=agent_service, user_service=user_service)
AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup
group = AgentsRouterGroup(ap, app)
await group.initialize()
return app.test_client()
async def test_create_agent_returns_bad_request_for_invalid_runner_config():
message = 'agent config runner_config must be an object'
agent_service = SimpleNamespace(create_agent=AsyncMock(side_effect=ValueError(message)))
client = await _create_test_client(agent_service)
response = await client.post(
'/api/v1/agents',
json={'name': 'Invalid Agent', 'config': {'runner_config': []}},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': message}
agent_service.create_agent.assert_awaited_once_with(
{'name': 'Invalid Agent', 'config': {'runner_config': []}},
)
async def test_update_agent_returns_bad_request_for_invalid_runner_config():
message = 'agent config runner.id must be a string'
agent_service = SimpleNamespace(update_agent=AsyncMock(side_effect=ValueError(message)))
client = await _create_test_client(agent_service)
response = await client.put(
'/api/v1/agents/agent-1',
json={'config': {'runner': {'id': 7}}},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': message}
agent_service.update_agent.assert_awaited_once_with(
'agent-1',
{'config': {'runner': {'id': 7}}},
)
@@ -0,0 +1,213 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(pipeline_service: SimpleNamespace, **extra_ap):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(pipeline_service=pipeline_service, user_service=user_service, **extra_ap)
router_class = import_module('langbot.pkg.api.http.controller.groups.pipelines.pipelines').PipelinesRouterGroup
group = router_class(ap, app)
await group.initialize()
return app.test_client()
@pytest.mark.parametrize(
('method', 'path', 'service_method'),
[
('post', '/api/v1/pipelines', 'create_pipeline'),
('put', '/api/v1/pipelines/pipeline-1', 'update_pipeline'),
],
)
async def test_pipeline_writes_return_bad_request_for_invalid_runner_security_field(
method,
path,
service_method,
):
message = "Pipeline runner_config['runner-1'] field 'enable-all-tools' must be a boolean"
pipeline_service = SimpleNamespace(
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
update_pipeline_extensions=AsyncMock(),
)
getattr(pipeline_service, service_method).side_effect = ValueError(message)
client = await _create_test_client(pipeline_service)
response = await getattr(client, method)(
path,
json={'config': {'ai': {'runner': {'id': 'runner-1'}}}},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': message}
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_pipeline_extensions_reject_non_boolean_resource_read(invalid_value):
pipeline_service = SimpleNamespace(
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
update_pipeline_extensions=AsyncMock(),
)
client = await _create_test_client(pipeline_service)
response = await client.put(
'/api/v1/pipelines/pipeline-1/extensions',
json={'mcp_resource_agent_read_enabled': invalid_value},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {
'code': -1,
'msg': "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean",
}
pipeline_service.update_pipeline_extensions.assert_not_awaited()
@pytest.mark.parametrize(
'field',
[
'enable_all_plugins',
'enable_all_mcp_servers',
'enable_all_skills',
],
)
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_pipeline_extensions_reject_non_boolean_enable_all_flags(field, invalid_value):
pipeline_service = SimpleNamespace(
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
update_pipeline_extensions=AsyncMock(),
)
client = await _create_test_client(pipeline_service)
response = await client.put(
'/api/v1/pipelines/pipeline-1/extensions',
json={field: invalid_value},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {
'code': -1,
'msg': f"Pipeline extension field '{field}' must be a boolean",
}
pipeline_service.update_pipeline_extensions.assert_not_awaited()
@pytest.mark.parametrize(
('field', 'invalid_value'),
[
('bound_plugins', 'author/plugin'),
('bound_mcp_servers', 'server-1'),
('bound_skills', 'skill-1'),
('bound_mcp_resources', {'uri': 'file:///README.md'}),
],
)
async def test_pipeline_extensions_return_bad_request_for_malformed_binding_lists(
field,
invalid_value,
):
message = f"Pipeline extension field '{field}' must be a list"
pipeline_service = SimpleNamespace(
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
update_pipeline_extensions=AsyncMock(side_effect=ValueError(message)),
)
client = await _create_test_client(pipeline_service)
response = await client.put(
'/api/v1/pipelines/pipeline-1/extensions',
json={field: invalid_value},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': message}
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(invalid_value):
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_plugins': invalid_value,
'enable_all_mcp_servers': invalid_value,
'enable_all_skills': invalid_value,
'mcp_resource_agent_read_enabled': invalid_value,
}
}
),
)
client = await _create_test_client(
pipeline_service,
plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])),
mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
logger=SimpleNamespace(warning=AsyncMock()),
)
response = await client.get(
'/api/v1/pipelines/pipeline-1/extensions',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['enable_all_plugins'] is False
assert payload['data']['enable_all_mcp_servers'] is False
assert payload['data']['enable_all_skills'] is False
assert payload['data']['mcp_resource_agent_read_enabled'] is False
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
async def test_pipeline_extensions_get_malformed_root_is_fail_closed(invalid_preferences):
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={'extensions_preferences': invalid_preferences}
),
)
client = await _create_test_client(
pipeline_service,
plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])),
mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
logger=SimpleNamespace(warning=AsyncMock()),
)
response = await client.get(
'/api/v1/pipelines/pipeline-1/extensions',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['enable_all_plugins'] is False
assert payload['data']['enable_all_mcp_servers'] is False
assert payload['data']['enable_all_skills'] is False
assert payload['data']['mcp_resource_agent_read_enabled'] is False
assert payload['data']['bound_plugins'] == []
assert payload['data']['bound_mcp_servers'] == []
assert payload['data']['bound_skills'] == []
assert payload['data']['bound_mcp_resources'] == []
@@ -0,0 +1,301 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(tool_mgr: SimpleNamespace, pipeline_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(
tool_mgr=tool_mgr,
pipeline_service=pipeline_service,
user_service=user_service,
)
router_class = import_module('langbot.pkg.api.http.controller.groups.resources.tools').ToolsRouterGroup
group = router_class(ap, app)
await group.initialize()
return app.test_client()
async def test_global_tool_selector_uses_unambiguous_host_catalog():
tool_mgr = SimpleNamespace(
get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'unique_tool', 'source': 'builtin'}])
)
pipeline_service = SimpleNamespace(get_pipeline=AsyncMock())
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['tools'] == [{'name': 'unique_tool', 'source': 'builtin'}]
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
)
pipeline_service.get_pipeline.assert_not_awaited()
async def test_pipeline_tool_selector_resolves_only_bound_sources():
tool_mgr = SimpleNamespace(
get_resolved_tool_catalog=AsyncMock(
return_value=[
{
'name': 'shared_tool',
'source': 'mcp',
'source_id': 'bound-mcp',
}
]
)
)
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_plugins': False,
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
'enable_all_mcp_servers': False,
'mcp_servers': ['bound-mcp'],
}
}
)
)
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools?pipeline_id=pipeline-1',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['tools'][0]['source_id'] == 'bound-mcp'
pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1')
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
['allowed/plugin'],
['bound-mcp'],
include_skill_authoring=True,
)
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
async def test_pipeline_tool_selector_malformed_enable_all_flags_fail_closed(invalid_value):
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_plugins': invalid_value,
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
'enable_all_mcp_servers': invalid_value,
'mcp_servers': ['bound-mcp'],
}
}
)
)
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools?pipeline_id=pipeline-1',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
['allowed/plugin'],
['bound-mcp'],
include_skill_authoring=True,
)
@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False])
async def test_pipeline_tool_selector_malformed_extension_root_uses_empty_allowlists(
invalid_preferences,
):
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={'extensions_preferences': invalid_preferences}
)
)
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools?pipeline_id=pipeline-1',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
[],
[],
include_skill_authoring=True,
)
async def test_pipeline_tool_selector_malformed_binding_lists_use_empty_allowlists():
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_plugins': True,
'plugins': 'allowed/plugin',
'enable_all_mcp_servers': True,
'mcp_servers': 'bound-mcp',
}
}
)
)
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools?pipeline_id=pipeline-1',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
[],
[],
include_skill_authoring=True,
)
@pytest.mark.parametrize('pipeline_query_key', ['pipeline_uuid', 'pipeline_id'])
async def test_tool_detail_uses_pipeline_scoped_catalog_and_path_tool_name(pipeline_query_key):
tool_mgr = SimpleNamespace(
get_resolved_tool_catalog=AsyncMock(
return_value=[
{
'name': 'namespace/unique_tool',
'description': 'Unique tool',
'human_desc': 'A unique tool',
'parameters': {'type': 'object'},
'source': 'plugin',
'source_name': 'allowed/plugin',
'source_id': 'allowed/plugin',
}
]
)
)
pipeline_service = SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_plugins': False,
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
'enable_all_mcp_servers': False,
'mcp_servers': ['bound-mcp'],
}
}
)
)
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
f'/api/v1/tools/namespace%2Funique_tool?{pipeline_query_key}=pipeline-1',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['tool'] == {
'name': 'namespace/unique_tool',
'description': 'Unique tool',
'human_desc': 'A unique tool',
'parameters': {'type': 'object'},
'source': 'plugin',
'source_name': 'allowed/plugin',
'source_id': 'allowed/plugin',
}
pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1')
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
['allowed/plugin'],
['bound-mcp'],
include_skill_authoring=True,
)
async def test_global_builtin_tool_detail_includes_nullable_source_id():
tool_mgr = SimpleNamespace(
get_resolved_tool_catalog=AsyncMock(
return_value=[
{
'name': 'exec',
'description': 'Execute a command',
'human_desc': 'Execute',
'parameters': {'type': 'object'},
'source': 'builtin',
'source_name': 'LangBot',
}
]
)
)
client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock()))
response = await client.get(
'/api/v1/tools/exec',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data']['tool']['source'] == 'builtin'
assert payload['data']['tool']['source_id'] is None
async def test_tool_detail_hides_ambiguous_or_missing_name():
tool_mgr = SimpleNamespace(
get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'other_tool', 'source': 'builtin'}])
)
client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock()))
response = await client.get(
'/api/v1/tools/shared_tool',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 404
tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
)
async def test_tool_detail_returns_pipeline_not_found_before_catalog_lookup():
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock())
pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=None))
client = await _create_test_client(tool_mgr, pipeline_service)
response = await client.get(
'/api/v1/tools/unique_tool?pipeline_uuid=missing-pipeline',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 404
assert await response.get_json() == {'code': -1, 'msg': 'pipeline not found'}
pipeline_service.get_pipeline.assert_awaited_once_with('missing-pipeline')
tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
+113 -146
View File
@@ -153,7 +153,6 @@ def make_app(
host_root: str = '',
workspace_quota_mb: int | None = None,
enabled: bool = True,
force_box_session_id_template: str = '',
):
box_config = {
'enabled': enabled,
@@ -172,30 +171,107 @@ def make_app(
return SimpleNamespace(
logger=logger,
instance_config=SimpleNamespace(
data={
'box': box_config,
'system': {'limitation': {'force_box_session_id_template': force_box_session_id_template}},
}
),
instance_config=SimpleNamespace(data={'box': box_config}),
)
def test_resolve_box_session_id_reads_current_runner_config():
def test_resolve_box_session_id_is_host_owned():
query = make_query(101)
query.pipeline_config = {
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': 'bot-{launcher_id}-{sender_id}',
},
},
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {'plugin:test/runner/default': {}},
},
}
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
assert service.resolve_box_session_id(query) == 'bot-test_user-test_user'
session_id = service.resolve_box_session_id(query)
assert session_id.startswith('lb-box-')
assert len(session_id) == 71
assert set(session_id.removeprefix('lb-box-')) <= set('0123456789abcdef')
assert 'test_user' not in session_id
def test_resolve_box_session_id_is_stable_and_conversation_scoped():
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
first = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='group',
launcher_id='room-1',
bot_uuid='bot-1',
)
same_conversation = pipeline_query.Query.model_construct(
query_id=2,
launcher_type='group',
launcher_id='room-1',
bot_uuid='bot-1',
)
other_conversation = pipeline_query.Query.model_construct(
query_id=3,
launcher_type='group',
launcher_id='room-2',
bot_uuid='bot-1',
)
assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_conversation)
assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_conversation)
def test_resolve_box_session_id_prefers_private_host_scope():
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
first = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='raw-launcher-a',
variables={'_host_box_scope': 'trusted-conversation'},
)
same_scope = pipeline_query.Query.model_construct(
query_id=2,
launcher_type='group',
launcher_id='raw-launcher-b',
variables={'_host_box_scope': 'trusted-conversation'},
)
other_scope = pipeline_query.Query.model_construct(
query_id=3,
launcher_type='person',
launcher_id='raw-launcher-a',
variables={'_host_box_scope': 'other-conversation'},
)
assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_scope)
assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_scope)
def test_resolve_box_session_id_hashes_unsafe_unicode_and_long_identity():
raw_identity = '用户/../../workspace/' + ('x' * 1000)
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='group/unsafe',
launcher_id=raw_identity,
variables={'_host_box_scope': raw_identity},
)
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
session_id = service.resolve_box_session_id(query)
assert session_id.startswith('lb-box-')
assert len(session_id) == 71
assert session_id.isascii()
assert '/' not in session_id
assert '用户' not in session_id
def test_resolve_box_session_id_rejects_missing_private_host_scope():
query = pipeline_query.Query.model_construct(
query_id=1,
launcher_type='person',
launcher_id='fallback-must-not-be-used',
variables={'_host_box_scope': None},
)
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
with pytest.raises(BoxValidationError, match='Host conversation scope'):
service.resolve_box_session_id(query)
@pytest.mark.asyncio
@@ -371,11 +447,13 @@ async def test_box_service_defaults_session_id_from_query():
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
result = await service.execute_tool({'command': 'pwd'}, make_query(7))
query = make_query(7)
expected_session_id = service.resolve_box_session_id(query)
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['session_id'] == 'person_test_user'
assert result['session_id'] == expected_session_id
assert result['ok'] is True
assert backend.start_calls == ['person_test_user']
assert backend.start_calls == [expected_session_id]
@pytest.mark.asyncio
@@ -387,15 +465,16 @@ async def test_box_service_session_id_uses_query_attributes_without_variables():
await service.initialize()
query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1')
expected_session_id = service.resolve_box_session_id(query)
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['session_id'] == 'group_room-1'
assert result['session_id'] == expected_session_id
assert result['ok'] is True
assert backend.start_calls == ['group_room-1']
assert backend.start_calls == [expected_session_id]
@pytest.mark.asyncio
async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries():
async def test_box_service_session_id_fails_closed_without_session_context():
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
@@ -403,92 +482,11 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri
await service.initialize()
query = pipeline_query.Query.model_construct(query_id=7)
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['session_id'] == 'query_7'
assert result['ok'] is True
assert backend.start_calls == ['query_7']
with pytest.raises(BoxValidationError, match='Host session context'):
await service.execute_tool({'command': 'pwd'}, query)
@pytest.mark.asyncio
async def test_box_service_forced_global_scope_overrides_pipeline_template():
"""SaaS guard: a non-empty ``force_box_session_id_template`` pins every
query to one shared sandbox regardless of the pipeline's own scope."""
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
service = BoxService(
make_app(logger, force_box_session_id_template='{global}'),
client=_InProcessBoxRuntimeClient(logger, runtime),
)
await service.initialize()
# Two distinct callers that would otherwise get separate sandboxes.
q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1')
q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice')
r1 = await service.execute_tool({'command': 'pwd'}, q1)
r2 = await service.execute_tool({'command': 'pwd'}, q2)
assert r1['session_id'] == 'global'
assert r2['session_id'] == 'global'
# Only one sandbox was ever started — the shared global one.
assert backend.start_calls == ['global']
def test_box_service_forced_template_ignores_pipeline_config():
"""The forced template wins even when the pipeline explicitly sets a
per-user scope — proving the override is not bypassable via pipeline config."""
logger = Mock()
service = BoxService(
make_app(logger, force_box_session_id_template='{global}'),
client=Mock(spec=BoxRuntimeClient),
)
query = pipeline_query.Query.model_construct(
query_id=7,
launcher_type='person',
launcher_id='test_user',
sender_id='test_user',
pipeline_config={
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'
}
},
}
},
)
assert service.resolve_box_session_id(query) == 'global'
def test_box_service_empty_forced_template_respects_pipeline_config():
"""An empty/whitespace forced template is a no-op: the pipeline's own
scope template is honoured (default non-SaaS behaviour)."""
logger = Mock()
service = BoxService(
make_app(logger, force_box_session_id_template=' '),
client=Mock(spec=BoxRuntimeClient),
)
query = pipeline_query.Query.model_construct(
query_id=7,
launcher_type='group',
launcher_id='room-1',
pipeline_config={
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': '{launcher_type}_{launcher_id}'
}
},
}
},
)
assert service.resolve_box_session_id(query) == 'group_room-1'
assert backend.start_calls == []
@pytest.mark.asyncio
@@ -539,11 +537,13 @@ async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_pat
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
result = await service.execute_tool({'command': 'pwd'}, make_query(15))
query = make_query(15)
expected_session_id = service.resolve_box_session_id(query)
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['ok'] is True
assert backend.start_calls == ['person_test_user']
assert backend.exec_calls == [('person_test_user', 'pwd')]
assert backend.start_calls == [expected_session_id]
assert backend.exec_calls == [(expected_session_id, 'pwd')]
assert backend.start_specs[0].host_path == os.path.realpath(host_dir)
@@ -606,41 +606,6 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path):
)
class TestGetSystemGuidance:
"""``get_system_guidance`` must ALWAYS advertise the per-query outbox path
when given a ``query_id`` — even with no inbound attachment — so files the
agent generates (QR codes, charts, rendered docs) are actually delivered.
The wrapper collects the outbox on every turn regardless of inbound files;
before this, the agent was only told the outbox path inside the
inbound-attachment note, so pure-generation turns produced files that were
silently dropped.
"""
def _service(self, logger=None):
logger = logger or Mock()
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
def test_guidance_includes_outbox_when_query_id_given(self):
service = self._service()
guidance = service.get_system_guidance(42)
assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance
assert 'delivered to the user automatically' in guidance
def test_guidance_omits_outbox_without_query_id(self):
service = self._service()
guidance = service.get_system_guidance()
assert service.OUTBOX_MOUNT_DIR not in guidance
# core exec guidance is still present
assert 'exec tool' in guidance
def test_guidance_outbox_independent_of_inbound_attachments(self):
# A bare query_id (the pure-generation case) still gets the outbox note.
service = self._service()
assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0)
@pytest.mark.asyncio
async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path):
logger = Mock()
@@ -1013,11 +978,13 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
await service.initialize()
query = make_query(45)
expected_session_id = service.resolve_box_session_id(query)
with pytest.raises(BoxValidationError, match='workspace quota exceeded after execution'):
await service.execute_tool({'command': 'generate-output'}, make_query(45))
await service.execute_tool({'command': 'generate-output'}, query)
assert backend.start_calls == ['person_test_user']
assert backend.stop_calls == ['person_test_user']
assert backend.start_calls == [expected_session_id]
assert backend.stop_calls == [expected_session_id]
@pytest.mark.asyncio
+1 -1
View File
@@ -198,7 +198,7 @@ class TestCommandOperatorBase:
# Should not raise
import asyncio
asyncio.get_event_loop().run_until_complete(op.initialize())
asyncio.run(op.initialize())
def test_execute_is_abstract(self):
"""execute() must be implemented by subclass."""
+5 -5
View File
@@ -28,7 +28,7 @@ class TestCheckDeps:
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
result = asyncio.run(check_deps())
assert result == []
@@ -48,7 +48,7 @@ class TestCheckDeps:
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
result = asyncio.run(check_deps())
assert 'requests' in result
assert 'openai' in result
@@ -64,7 +64,7 @@ class TestCheckDeps:
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
result = asyncio.run(check_deps())
# Should include all required_deps keys
assert len(result) == len(required_deps)
@@ -111,7 +111,7 @@ class TestPrecheckPluginDeps:
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
asyncio.run(precheck_plugin_deps())
mock_install.assert_not_called()
@@ -134,6 +134,6 @@ class TestPrecheckPluginDeps:
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
asyncio.run(precheck_plugin_deps())
mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
+12 -23
View File
@@ -30,23 +30,9 @@ def mock_circular_import_chain():
make_pipeline_handler_import_mocks,
get_handler_modules_to_clear,
)
from langbot_plugin.api.entities.builtin.provider.message import Message
mocks = make_pipeline_handler_import_mocks()
# Create a default runner that yields a simple response
class DefaultRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
yield Message(role='assistant', content='fake response')
mocks['langbot.pkg.provider.runner'].preregistered_runners = [DefaultRunner]
clear = get_handler_modules_to_clear('chat')
with isolated_sys_modules(mocks=mocks, clear=clear):
@@ -56,22 +42,27 @@ def mock_circular_import_chain():
@pytest.fixture
def fake_app():
"""Create FakeApp instance."""
import sys
from langbot_plugin.api.entities.builtin.provider.message import Message
app = FakeApp()
class FakeAgentRunOrchestrator:
runner_class = None
async def try_claim_steering_from_query(self, query):
return False
async def run_from_query(self, query):
runner_cls = sys.modules['langbot.pkg.provider.runner'].preregistered_runners[0]
runner = runner_cls(app, {})
if self.runner_class is None:
yield Message(role='assistant', content='fake response')
return
runner = self.runner_class(app, {})
async for result in runner.run(query):
yield result
def resolve_runner_id_for_telemetry(self, query):
return 'local-agent'
return 'plugin:langbot-team/LocalAgent/default'
app.agent_run_orchestrator = FakeAgentRunOrchestrator()
return app
@@ -89,13 +80,11 @@ def mock_event_ctx():
@pytest.fixture
def set_runner():
"""Factory fixture to set a custom runner for tests."""
def set_runner(fake_app):
"""Configure the orchestrator test double for one test."""
def _set_runner(runner_class):
import sys
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class]
fake_app.agent_run_orchestrator.runner_class = runner_class
return _set_runner
+93 -1
View File
@@ -178,7 +178,7 @@ def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
'mcp-resource-agent-read-enabled': False,
},
}
},
}
}
pipeline_entity.extensions_preferences = {
@@ -213,3 +213,95 @@ def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
def test_runtime_pipeline_mcp_resource_read_flag_fails_closed(mock_app, invalid_value):
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {
'ai': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
'mcp-resource-agent-read-enabled': invalid_value,
}
},
}
}
pipeline_entity.extensions_preferences = {'mcp_resource_agent_read_enabled': True}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
def test_runtime_pipeline_extension_enable_all_flags_fail_closed(mock_app, invalid_value):
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {
'enable_all_plugins': invalid_value,
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
'enable_all_mcp_servers': invalid_value,
'mcp_servers': ['bound-mcp'],
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.enable_all_plugins is False
assert runtime_pipeline.bound_plugins == ['allowed/plugin']
assert runtime_pipeline.enable_all_mcp_servers is False
assert runtime_pipeline.bound_mcp_servers == ['bound-mcp']
@pytest.mark.parametrize('invalid_preferences', [None, [], '', 0, False])
def test_runtime_pipeline_malformed_extension_root_disables_all_extensions(
mock_app,
invalid_preferences,
):
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = invalid_preferences
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.enable_all_plugins is False
assert runtime_pipeline.bound_plugins == []
assert runtime_pipeline.enable_all_mcp_servers is False
assert runtime_pipeline.bound_mcp_servers == []
assert runtime_pipeline.mcp_resource_attachments == []
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
def test_runtime_pipeline_malformed_extension_lists_are_empty_allowlists(mock_app):
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {
'enable_all_plugins': True,
'plugins': 'allowed/plugin',
'enable_all_mcp_servers': True,
'mcp_servers': 'bound-mcp',
'mcp_resources': 'file:///README.md',
'mcp_resource_agent_read_enabled': True,
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.enable_all_plugins is False
assert runtime_pipeline.bound_plugins == []
assert runtime_pipeline.enable_all_mcp_servers is False
assert runtime_pipeline.bound_mcp_servers == []
assert runtime_pipeline.mcp_resource_attachments == []
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
+132 -16
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -113,7 +112,7 @@ class TestPreProcessorNormalText:
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
# Mock tool manager
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
# Mock plugin connector
mock_event_ctx = Mock()
@@ -151,7 +150,7 @@ class TestPreProcessorNormalText:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -189,7 +188,7 @@ class TestPreProcessorEmptyMessage:
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -231,7 +230,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -279,7 +278,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -317,7 +316,7 @@ class TestPreProcessorModelSelection:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
attach_agent_runner_descriptor(app)
mock_event_ctx = Mock()
@@ -369,7 +368,7 @@ class TestPreProcessorModelSelection:
raise ValueError(f'Model {uuid} not found')
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
attach_agent_runner_descriptor(app)
mock_event_ctx = Mock()
@@ -411,7 +410,7 @@ class TestPreProcessorVariables:
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -448,7 +447,7 @@ class TestPreProcessorVariables:
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -463,12 +462,62 @@ class TestPreProcessorVariables:
assert 'group_name' in variables
assert 'sender_name' in variables
@pytest.mark.asyncio
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
@pytest.mark.parametrize(
('configured_skills', 'expected_skills'),
[
(['bound-skill'], ['bound-skill']),
(None, []),
('bound-skill', []),
],
)
async def test_malformed_enable_all_skills_flag_uses_bound_skills(
self,
invalid_value,
configured_skills,
expected_skills,
):
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
app.pipeline_service.get_pipeline = AsyncMock(
return_value={
'extensions_preferences': {
'enable_all_skills': invalid_value,
'skills': configured_skills,
}
}
)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
result = await preproc.PreProcessor(app).process(text_query('hello'), 'PreProcessor')
assert result.new_query.variables['_pipeline_bound_skills'] == expected_skills
class TestPreProcessorToolSelection:
"""Tests for Local Agent tool selection."""
"""Tests for generic AgentRunner tool selection."""
@pytest.mark.asyncio
async def test_local_agent_filters_selected_tools(self):
async def test_agent_runner_filters_selected_tools(self):
"""Only selected tools should be exposed when all-tools mode is off."""
preproc = get_preproc_module()
@@ -488,11 +537,28 @@ class TestPreProcessorToolSelection:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
SimpleNamespace(name='exec'),
SimpleNamespace(name='plugin_tool'),
SimpleNamespace(name='mcp_tool'),
{
'name': 'exec',
'source': 'builtin',
'description': 'Execute',
'parameters': {},
},
{
'name': 'plugin_tool',
'source': 'plugin',
'source_id': 'test/plugin',
'description': 'Plugin tool',
'parameters': {},
},
{
'name': 'mcp_tool',
'source': 'mcp',
'source_id': 'mcp-server',
'description': 'MCP tool',
'parameters': {},
},
]
)
@@ -516,3 +582,53 @@ class TestPreProcessorToolSelection:
result = await stage.process(query, 'PreProcessor')
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
assert result.new_query.variables['_host_tool_source_refs'] == {
'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'},
}
class TestPreProcessorMCPResourceContext:
"""Tests for deferring MCP context until the run-scoped execution input."""
@pytest.mark.asyncio
async def test_pinned_context_does_not_mutate_preprocessed_input(self):
preproc = get_preproc_module()
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = 'conversation-1'
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=[])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
mcp_loader = Mock()
mcp_loader.build_resource_context_for_query = AsyncMock(return_value='Pinned documentation')
app.tool_mgr.mcp_tool_loader = mcp_loader
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
attach_agent_runner_descriptor(app, tool_calling=False)
query = text_query('hello')
query.launcher_id = '12345'
query.pipeline_config = agent_runner_pipeline_config(
{'primary': 'primary-model-uuid', 'fallbacks': []},
)
result = await preproc.PreProcessor(app).process(query, 'PreProcessor')
event = QueryEntryAdapter.query_to_event(result.new_query)
assert event.input.text == 'hello'
assert 'Pinned documentation' not in str(event.input.contents)
mcp_loader.build_resource_context_for_query.assert_not_awaited()
@@ -71,7 +71,10 @@ class TestEventRouteTrace:
"""Synthetic test dispatch runs the route but does not call the real adapter."""
import langbot_plugin.api.entities.builtin.provider.message as provider_message
captured_envelopes = []
async def fake_run(envelope, binding):
captured_envelopes.append(envelope)
yield provider_message.Message(role='assistant', content='test response')
bot = self._make_bot(
@@ -106,6 +109,7 @@ class TestEventRouteTrace:
config={},
logger=bot.logger,
send_message=AsyncMock(),
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
)
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
@@ -114,6 +118,64 @@ class TestEventRouteTrace:
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'][0]['method'] == 'send_message'
assert captured_envelopes[0].delivery.supports_edit is False
assert captured_envelopes[0].delivery.supports_reaction is False
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
@pytest.mark.asyncio
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
bot = self._make_bot(
[
{
'id': 'agent-binding',
'enabled': True,
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 0,
'order': 0,
}
]
)
malformed_agent = {
'uuid': 'agent-1',
'kind': 'agent',
'enabled': True,
'supported_event_patterns': ['platform.member.joined'],
'config': {
'runner': {'id': 'runner-1'},
'runner_config': {'runner-1': ['invalid']},
},
}
valid_agent = {
**malformed_agent,
'config': {
'runner': {'id': 'runner-1'},
'runner_config': {'runner-1': {}},
},
}
runner_calls = []
async def fake_run(envelope, binding):
runner_calls.append((envelope, binding))
if False:
yield None
bot.ap = SimpleNamespace(
agent_service=SimpleNamespace(get_agent=AsyncMock(side_effect=[malformed_agent, valid_agent])),
agent_run_orchestrator=SimpleNamespace(run=fake_run),
)
event = SimpleNamespace(type='platform.member.joined')
failed = await bot._dispatch_eba_event_to_processor(event, Mock())
delivered = await bot._dispatch_eba_event_to_processor(event, Mock())
assert failed['status'] == 'failed'
assert failed['failure_code'] == 'runner_failed'
assert failed['reason'] == 'Agent configuration is invalid'
assert delivered['status'] == 'delivered'
assert len(runner_calls) == 1
@pytest.mark.asyncio
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
@@ -236,6 +298,41 @@ class TestEventRouteTrace:
assert event.sender.nickname == 'QA User'
assert str(event.message_chain) == 'hello'
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
"""Runner delivery context reflects the active adapter's declared APIs."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
bot = self._make_bot([])
bot.bot_entity.uuid = 'bot-1'
adapter = SimpleNamespace(
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'edit_message', None])
)
event = events.MessageReceivedEvent(
message_id='message-1',
message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1', nickname='QA User'),
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
)
envelope = bot._eba_event_to_agent_envelope(event, adapter)
assert envelope.delivery.supports_edit is True
assert envelope.delivery.supports_reaction is True
assert envelope.delivery.platform_capabilities == {
'adapter': 'SimpleNamespace',
'event_type': 'message.received',
'supported_apis': ['send_message', 'edit_message', 'add_reaction'],
}
def test_adapter_delivery_capabilities_degrade_on_invalid_declaration(self):
"""Broken third-party capability declarations do not block event dispatch."""
from langbot.pkg.platform.botmgr import RuntimeBot
adapter = SimpleNamespace(get_supported_apis=Mock(side_effect=RuntimeError('broken manifest')))
assert RuntimeBot._get_adapter_supported_apis(adapter) == []
class TestEventLoggerMetadata:
"""Test platform EventLogger metadata compatibility."""
@@ -381,7 +478,57 @@ class TestEBAEventBindings:
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.resource_policy.allow_all_tools is True
assert binding.resource_policy.allowed_tool_names is None
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'
def test_agent_product_to_binding_projects_selected_tool_policy(self):
"""Independent Agents use the same standard runner resource fields as Pipelines."""
from langbot.pkg.platform.botmgr import RuntimeBot
binding = RuntimeBot._agent_product_to_binding(
{
'uuid': 'agent-1',
'config': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
'enable-all-tools': False,
'tools': ['exec', 'plugin_tool'],
'knowledge-bases': ['kb-1'],
}
},
},
},
{'id': 'binding-1'},
'platform.member.joined',
'bot-1',
)
assert binding is not None
assert binding.resource_policy.allow_all_tools is False
assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool']
assert binding.resource_policy.allowed_kb_uuids == ['kb-1']
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
"""An empty config runner stays unconfigured even if component_ref is stale."""
from langbot.pkg.platform.botmgr import RuntimeBot
binding = RuntimeBot._agent_product_to_binding(
{
'uuid': 'agent-1',
'component_ref': 'plugin:test/stale/default',
'config': {
'runner': {'id': ''},
'runner_config': {},
},
},
{'id': 'binding-1'},
'platform.member.joined',
'bot-1',
)
assert binding is None
@@ -270,6 +270,8 @@ async def test_telegram_converter_maps_bot_status_events():
'can_invite_users': False,
'can_pin_messages': False,
'can_manage_topics': False,
'can_edit_tag': False,
'can_react_to_messages': False,
'until_date': 0,
}
invited = make_update(
+85 -7
View File
@@ -32,6 +32,7 @@ class TestHandlerQueryVariables:
app.logger = SimpleNamespace()
app.logger.debug = MagicMock()
app.logger.warning = MagicMock()
return app
@@ -71,6 +72,90 @@ class TestHandlerQueryVariables:
assert response.code == 0
assert mock_query.variables['test_var'] == 'test_value'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'key',
[
'_host_box_scope',
'_host_tool_source_refs',
'_pipeline_bound_plugins',
'_pipeline_bound_mcp_servers',
'_pipeline_bound_skills',
'_pipeline_mcp_resource_attachments',
'_pipeline_mcp_resource_agent_read_enabled',
'_activated_skills',
'_fallback_model_uuids',
'_monitoring_message_id',
'_sandbox_outbound_collected',
'_authorized_models',
'_permission_tools',
'_routed_by_rule',
],
)
async def test_set_query_var_rejects_host_reserved_keys(self, mock_app, key):
runtime_handler = make_handler(mock_app)
original_variables = {key: 'host-owned'}
mock_query = SimpleNamespace(variables=original_variables.copy())
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': 'plugin-overwrite',
}
)
assert response.code != 0
assert response.message == f'Query variable {key!r} is reserved for LangBot Host'
assert mock_query.variables == original_variables
mock_app.logger.warning.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'key',
[
'business_context',
'_ltm_context',
'_knowledge_base_uuids',
'_skill_authoring_post_response_candidate',
],
)
async def test_set_query_var_keeps_plugin_business_variables_writable(self, mock_app, key):
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace(variables={})
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': {'plugin': 'value'},
}
)
assert response.code == 0
assert mock_query.variables[key] == {'plugin': 'value'}
@pytest.mark.asyncio
@pytest.mark.parametrize('key', ['', None, 7])
async def test_set_query_var_rejects_invalid_key_shapes(self, mock_app, key):
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace(variables={})
mock_app.query_pool.cached_queries['test-query'] = mock_query
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
{
'query_id': 'test-query',
'key': key,
'value': 'value',
}
)
assert response.code != 0
assert response.message == 'Query variable key must be a non-empty string'
assert mock_query.variables == {}
@pytest.mark.asyncio
async def test_get_query_var_success(self, mock_app):
"""Test get_query_var retrieves variable from query."""
@@ -205,10 +290,3 @@ class TestConstantsSemanticVersion:
assert hasattr(constants, 'edition')
assert constants.edition == 'community'
def test_required_database_version_exists(self):
"""Test database version constant."""
from langbot.pkg.utils import constants
assert hasattr(constants, 'required_database_version')
assert isinstance(constants.required_database_version, int)
+431 -91
View File
@@ -10,6 +10,8 @@ import pytest
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError
def make_handler(app):
"""Create a RuntimeConnectionHandler with mocked external connection."""
@@ -456,6 +458,14 @@ class TestAgentRunProxyActions:
mock_app.model_mgr.get_rerank_model_by_uuid = AsyncMock()
mock_app.tool_mgr = Mock()
mock_app.tool_mgr.execute_func_call = AsyncMock(return_value={'ok': True})
mock_app.tool_mgr.get_tool_detail = AsyncMock(
return_value={
'name': 'test/search',
'description': 'Search test data',
'human_desc': 'Search',
'parameters': {'type': 'object'},
}
)
return mock_app
@staticmethod
@@ -463,9 +473,7 @@ class TestAgentRunProxyActions:
return SimpleNamespace(
pipeline_config={'output': {'misc': {'remove-think': remove_think}}},
variables={},
prompt=SimpleNamespace(
messages=[provider_message.Message(role='system', content='effective prompt')]
),
prompt=SimpleNamespace(messages=[provider_message.Message(role='system', content='effective prompt')]),
)
@pytest.mark.asyncio
@@ -491,10 +499,12 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
})
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
}
)
finally:
await registry.unregister(run_id)
@@ -535,19 +545,23 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
}
)
finally:
await registry.unregister(run_id)
@@ -603,12 +617,14 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
finally:
await registry.unregister(run_id)
@@ -647,19 +663,23 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7},
})
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'temperature': 0.7},
}
)
finally:
await registry.unregister(run_id)
@@ -692,12 +712,14 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
finally:
await registry.unregister(run_id)
@@ -742,20 +764,24 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'max_tokens': 256},
'remove_think': True,
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [
{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}
],
'extra_args': {'max_tokens': 256},
'remove_think': True,
}
)
async for response in stream:
responses.append(response)
finally:
@@ -801,12 +827,14 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_002',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
async for response in stream:
responses.append(response)
finally:
@@ -856,12 +884,14 @@ class TestAgentRunProxyActions:
responses = []
try:
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
})
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_stream_usage_001',
'messages': [{'role': 'user', 'content': 'hello'}],
}
)
async for response in stream:
responses.append(response)
finally:
@@ -888,18 +918,28 @@ class TestAgentRunProxyActions:
runner_id='plugin:test/runner/default',
query_id=903,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'test/search'}]),
resources=make_agent_resources(
tools=[
{
'tool_name': 'test/search',
'source': 'mcp',
'source_id': 'bound-mcp',
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
'parameters': {'q': 'langbot'},
})
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
'parameters': {'q': 'langbot'},
}
)
finally:
await registry.unregister(run_id)
@@ -909,8 +949,304 @@ class TestAgentRunProxyActions:
name='test/search',
parameters={'q': 'langbot'},
query=query,
source_ref={'source': 'mcp', 'source_id': 'bound-mcp'},
)
@pytest.mark.asyncio
async def test_get_tool_detail_passes_frozen_source_ref(self, app):
"""GET_TOOL_DETAIL resolves only the implementation frozen for the run."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_get_tool_detail_source'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(
tools=[
{
'tool_name': 'test/search',
'source': 'mcp',
'source_id': 'bound-mcp',
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.GET_TOOL_DETAIL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
app.tool_mgr.get_tool_detail.assert_awaited_once_with(
'test/search',
source_ref={'source': 'mcp', 'source_id': 'bound-mcp'},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
('action', 'action_payload', 'manager_method'),
[
pytest.param(
PluginToRuntimeAction.CALL_TOOL,
{'parameters': {'q': 'langbot'}},
'execute_func_call',
id='call',
),
pytest.param(
PluginToRuntimeAction.GET_TOOL_DETAIL,
{},
'get_tool_detail',
id='detail',
),
],
)
@pytest.mark.parametrize(
'tool_resource',
[
pytest.param(
{'tool_name': 'test/search', 'source_id': 'bound-mcp'},
id='missing-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp'},
id='missing-source-id',
),
pytest.param(
{'tool_name': 'test/search', 'source': 7, 'source_id': 'bound-mcp'},
id='invalid-source-type',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp', 'source_id': 7},
id='invalid-source-id-type',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'mcp', 'source_id': None},
id='unscoped-mcp-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'plugin', 'source_id': None},
id='unscoped-plugin-source',
),
pytest.param(
{'tool_name': 'test/search', 'source': 'builtin', 'source_id': 'unexpected'},
id='builtin-source-id',
),
],
)
async def test_tool_actions_reject_malformed_frozen_source_identity(
self,
app,
action,
action_payload,
manager_method,
tool_resource,
):
"""Run-scoped tool actions never fall back from a malformed authorization snapshot."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = f'run_proxy_malformed_source_{action.value}'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[tool_resource]),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[action.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'test/search',
**action_payload,
}
)
finally:
await registry.unregister(run_id)
assert response.code != 0
assert response.message == 'Tool test/search has an invalid frozen source identity for this agent run'
getattr(app.tool_mgr, manager_method).assert_not_awaited()
@pytest.mark.asyncio
async def test_call_tool_returns_error_when_host_denies_execution(self, app):
"""CALL_TOOL preserves the existing error response when a loader denies execution."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_call_tool_denied'
query = self.query()
app.query_pool.cached_queries[907] = query
app.tool_mgr.execute_func_call.side_effect = ToolExecutionDeniedError(
'langbot_mcp_read_resource',
'MCP resource agent reads are disabled',
)
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=907,
plugin_identity='test/runner',
resources=make_agent_resources(
tools=[
{
'tool_name': 'langbot_mcp_read_resource',
'source': 'mcp',
'source_id': None,
}
]
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'langbot_mcp_read_resource',
'parameters': {'server_name': 'docs', 'uri': 'file:///README.md'},
}
)
finally:
await registry.unregister(run_id)
assert response.code != 0
assert 'Failed to execute tool langbot_mcp_read_resource' in response.message
assert 'MCP resource agent reads are disabled' in response.message
@pytest.mark.asyncio
async def test_call_tool_falls_back_to_host_execution_query(self, app):
"""Pure EBA runs use their Host-only Query when no cached Query exists."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_call_tool_event_first'
query = self.query()
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]),
execution_query=query,
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'exec',
'parameters': {'command': 'pwd'},
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
assert getattr(query, '_agent_run_session')['run_id'] == run_id
app.tool_mgr.execute_func_call.assert_awaited_once_with(
name='exec',
parameters={'command': 'pwd'},
query=query,
source_ref={'source': 'builtin', 'source_id': None},
)
@pytest.mark.asyncio
async def test_pure_event_call_tool_exec_uses_native_host_path(self, app):
"""A pure EBA run can call authorized native exec through CALL_TOOL."""
from langbot.pkg.agent.runner.execution_context import build_execution_query
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
event = AgentEventEnvelope(
event_id='event-native-exec',
event_type='message.received',
source='platform',
bot_id='bot-1',
conversation_id='person_user-1',
input=AgentInput(text='run pwd'),
delivery=DeliveryContext(
surface='platform',
reply_target={'target_type': 'person', 'target_id': 'user-1'},
platform_capabilities={'adapter': 'TestAdapter'},
),
)
query = build_execution_query(event, [])
app.box_service = SimpleNamespace(
execute_tool=AsyncMock(
return_value={
'ok': True,
'session_id': 'lb-box-test',
'stdout': '/workspace\n',
'stderr': '',
}
),
)
app.monitoring_service = None
app.skill_mgr = None
native_loader = NativeToolLoader(app)
native_loader._backend_available = True
tool_mgr = ToolManager(app)
tool_mgr.native_tool_loader = native_loader
tool_mgr.plugin_tool_loader = Mock()
tool_mgr.mcp_tool_loader = Mock()
tool_mgr.skill_tool_loader = Mock()
app.tool_mgr = tool_mgr
run_id = 'run_pure_event_native_exec'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]),
execution_query=query,
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'tool_name': 'exec',
'parameters': {'command': 'pwd'},
}
)
finally:
await registry.unregister(run_id)
assert response.code == 0
assert response.data['result']['ok'] is True
assert response.data['result']['stdout'] == '/workspace\n'
app.box_service.execute_tool.assert_awaited_once_with({'command': 'pwd'}, query)
@pytest.mark.asyncio
async def test_invoke_rerank_uses_authorized_model_and_extra_args(self, app):
"""INVOKE_RERANK validates run-scoped model access and merges model extra_args."""
@@ -928,10 +1264,12 @@ class TestAgentRunProxyActions:
)
provider = SimpleNamespace(
invoke_rerank=AsyncMock(return_value=[
{'index': 0, 'relevance_score': 0.2},
{'index': 1, 'relevance_score': 0.9},
]),
invoke_rerank=AsyncMock(
return_value=[
{'index': 0, 'relevance_score': 0.2},
{'index': 1, 'relevance_score': 0.9},
]
),
)
rerank_model = SimpleNamespace(
model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}),
@@ -941,15 +1279,17 @@ class TestAgentRunProxyActions:
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'rerank_model_uuid': 'rerank_001',
'query': 'hello',
'documents': ['a', 'b'],
'top_k': 1,
'extra_args': {'top_n': 2},
})
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'rerank_model_uuid': 'rerank_001',
'query': 'hello',
'documents': ['a', 'b'],
'top_k': 1,
'extra_args': {'top_n': 2},
}
)
finally:
await registry.unregister(run_id)
@@ -15,6 +15,20 @@ from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
@pytest.fixture(autouse=True)
def _isolate_loopback_transport_from_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep real loopback transport tests independent of workstation proxies."""
for variable in (
'ALL_PROXY',
'all_proxy',
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
):
monkeypatch.delenv(variable, raising=False)
class _TransportProbe:
def __init__(self, streamable_status: int | None) -> None:
self.streamable_status = streamable_status
+124 -12
View File
@@ -8,9 +8,12 @@ import httpx
import pytest
from mcp import types as mcp_types
from langbot.pkg.agent.runner.execution_context import project_mcp_resource_config
from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY,
MCP_READ_RESOURCE_SCHEMA,
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
MCPLoader,
@@ -257,24 +260,43 @@ async def test_mcp_loader_can_hide_synthetic_resource_tools():
@pytest.mark.asyncio
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
async def test_mcp_loader_get_tool_returns_synthetic_resource_schema():
loader = MCPLoader(_app())
loader.sessions = {'docs': _connected_session()}
tool = await loader.get_tool(MCP_TOOL_READ_RESOURCE)
assert tool is not None
assert tool.name == MCP_TOOL_READ_RESOURCE
assert tool.parameters == MCP_READ_RESOURCE_SCHEMA
@pytest.mark.asyncio
@pytest.mark.parametrize('read_enabled', [False, 0, None, 'false'])
@pytest.mark.parametrize(
('tool_name', 'parameters'),
[
(MCP_TOOL_LIST_RESOURCES, {'server_name': 'docs'}),
(MCP_TOOL_READ_RESOURCE, {'server_name': 'docs', 'uri': 'file:///README.md'}),
],
)
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled(
read_enabled,
tool_name,
parameters,
):
loader = MCPLoader(_app())
session = _connected_session()
loader.sessions = {'docs': session}
query = SimpleNamespace(
variables={
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_agent_read_enabled': False,
}
)
result = await loader.invoke_tool(
MCP_TOOL_READ_RESOURCE,
{'server_name': 'docs', 'uri': 'file:///README.md'},
query = SimpleNamespace(variables={'_pipeline_bound_mcp_servers': ['srv-1']})
project_mcp_resource_config(
query,
{'mcp-resource-agent-read-enabled': read_enabled},
)
assert result[0].text == 'Error: MCP resource agent reads are disabled.'
with pytest.raises(ToolExecutionDeniedError, match='MCP resource agent reads are disabled'):
await loader.invoke_tool(tool_name, parameters, query)
session.session.read_resource.assert_not_called()
@@ -321,3 +343,93 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
docs.session.read_resource.assert_awaited_once()
other.session.read_resource.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('enabled_config', 'expected_enabled'),
[
pytest.param({}, True, id='missing'),
pytest.param({'enabled': True}, True, id='true'),
pytest.param({'enabled': False}, False, id='false'),
pytest.param({'enabled': 0}, False, id='zero'),
pytest.param({'enabled': None}, False, id='none'),
pytest.param({'enabled': 'false'}, False, id='string-false'),
],
)
async def test_build_resource_context_attachment_enabled_fails_closed(enabled_config, expected_enabled):
loader = MCPLoader(_app())
session = _connected_session(name='docs', uuid='srv-1')
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.TextResourceContents(
uri='file:///README.md',
mimeType='text/markdown',
text='enabled attachment',
)
]
)
loader.sessions = {'docs': session}
attachment = {
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
**enabled_config,
}
query = SimpleNamespace(
variables={
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_attachments': [attachment],
}
)
context = await loader.build_resource_context_for_query(query)
if expected_enabled:
assert 'enabled attachment' in context
session.session.read_resource.assert_awaited_once()
else:
assert context == ''
session.session.read_resource.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('field_name', 'invalid_value'),
[
pytest.param('max_tokens', '100', id='string-tokens'),
pytest.param('max_tokens', 0, id='zero-tokens'),
pytest.param('max_tokens', -1, id='negative-tokens'),
pytest.param('max_tokens', True, id='boolean-tokens'),
pytest.param('max_bytes', '4096', id='string-bytes'),
pytest.param('max_bytes', 0, id='zero-bytes'),
pytest.param('max_bytes', -1, id='negative-bytes'),
pytest.param('max_bytes', True, id='boolean-bytes'),
],
)
async def test_build_resource_context_invalid_attachment_limits_fail_closed(field_name, invalid_value):
ap = _app()
loader = MCPLoader(ap)
session = _connected_session(name='docs', uuid='srv-1')
loader.sessions = {'docs': session}
query = SimpleNamespace(
variables={
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_attachments': [
{
'server_uuid': 'srv-1',
'server_name': 'docs',
'uri': 'file:///README.md',
'mode': 'pinned',
field_name: invalid_value,
}
],
}
)
context = await loader.build_resource_context_for_query(query)
assert context == ''
session.session.read_resource.assert_not_awaited()
ap.logger.warning.assert_called_once()
@@ -193,11 +193,13 @@ class TestPersistActivatedSkill:
query = SimpleNamespace(variables={ACTIVATED_SKILLS_KEY: {'pdf': {'name': 'pdf'}}})
query._agent_run_session = {
'runner_id': 'plugin:langbot-team/LocalAgent/default',
'state_context': {
'scope_keys': {'conversation': 'conv-scope-key'},
'binding_identity': 'binding-1',
'conversation_id': 'c1',
'runner_id': 'plugin:test/runner/default',
'authorization': {
'state_context': {
'scope_keys': {'conversation': 'conv-scope-key'},
'binding_identity': 'binding-1',
'conversation_id': 'c1',
},
},
}
@@ -214,7 +216,7 @@ class TestPersistActivatedSkill:
assert kwargs['state_key'] == ACTIVATED_SKILL_NAMES_STATE_KEY
assert kwargs['value'] == ['pdf']
assert kwargs['scope'] == 'conversation'
assert kwargs['runner_id'] == 'plugin:langbot-team/LocalAgent/default'
assert kwargs['runner_id'] == 'plugin:test/runner/default'
assert kwargs['binding_identity'] == 'binding-1'
@pytest.mark.asyncio
+118 -2
View File
@@ -7,12 +7,15 @@ Tests cover:
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import pytest
from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError
def get_toolmgr_module():
@@ -259,6 +262,119 @@ class TestToolManagerExecuteFuncCall:
mock_plugin_loader.invoke_tool.assert_called_once()
mock_mcp_loader.invoke_tool.assert_not_called()
@pytest.mark.asyncio
async def test_bound_mcp_source_wins_over_unbound_plugin_name_collision(self, mock_app_with_loaders):
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
manager = toolmgr.ToolManager(mock_app)
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
query = Mock(variables={})
result = await manager.execute_func_call(
'shared_tool',
{'value': 1},
query,
source_ref={'source': 'mcp', 'source_id': 'bound-mcp'},
)
assert result == 'mcp_result'
mock_plugin_loader.has_tool.assert_not_awaited()
mock_plugin_loader.invoke_tool.assert_not_awaited()
mock_mcp_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='bound-mcp')
mock_mcp_loader.invoke_tool.assert_awaited_once_with(
'shared_tool',
{'value': 1},
query,
source_id='bound-mcp',
)
@pytest.mark.asyncio
async def test_bound_plugin_source_selects_one_of_two_same_name_plugins(self, mock_app_with_loaders):
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
manager = toolmgr.ToolManager(mock_app)
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
query = Mock(variables={})
result = await manager.execute_func_call(
'shared_tool',
{},
query,
source_ref={'source': 'plugin', 'source_id': 'authorized/plugin'},
)
assert result == 'plugin_result'
mock_plugin_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='authorized/plugin')
mock_plugin_loader.invoke_tool.assert_awaited_once_with(
'shared_tool',
{},
query,
source_id='authorized/plugin',
)
mock_mcp_loader.has_tool.assert_not_awaited()
@pytest.mark.asyncio
async def test_disabled_synthetic_mcp_tool_is_monitored_as_error(
self,
mock_app_with_loaders,
sample_query,
):
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_app.monitoring_service = SimpleNamespace(record_tool_call=AsyncMock())
sample_query.variables = {}
sample_query.launcher_type = None
sample_query.launcher_id = None
sample_query.bot_uuid = 'bot-1'
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
mock_mcp_loader.invoke_tool = AsyncMock(
side_effect=ToolExecutionDeniedError(
'langbot_mcp_read_resource',
'MCP resource agent reads are disabled',
)
)
manager = toolmgr.ToolManager(mock_app)
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
with pytest.raises(ToolExecutionDeniedError):
await manager.execute_func_call(
'langbot_mcp_read_resource',
{'server_name': 'docs', 'uri': 'file:///README.md'},
sample_query,
source_ref={'source': 'mcp', 'source_id': None},
)
record = mock_app.monitoring_service.record_tool_call
record.assert_awaited_once()
assert record.await_args.kwargs['status'] == 'error'
assert record.await_args.kwargs['tool_source'] == 'mcp'
assert 'MCP resource agent reads are disabled' in record.await_args.kwargs['error_message']
class TestToolManagerSourceResolution:
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['mcp', 'plugin'])
async def test_same_name_implementations_in_one_scope_are_hidden(self, source):
toolmgr = get_toolmgr_module()
app = Mock(logger=Mock())
manager = toolmgr.ToolManager(app)
manager.get_tool_catalog = AsyncMock(
return_value=[
{'name': 'shared_tool', 'source': source, 'source_id': 'source-a'},
{'name': 'shared_tool', 'source': source, 'source_id': 'source-b'},
{'name': 'unique_tool', 'source': 'builtin'},
]
)
catalog = await manager.get_resolved_tool_catalog()
assert [item['name'] for item in catalog] == ['unique_tool']
app.logger.warning.assert_called_once()
class TestToolManagerShutdown:
"""Tests for shutdown method."""
@@ -0,0 +1,157 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
MCPLoader,
MCPSessionStatus,
)
from langbot.pkg.provider.tools.loaders.plugin import PluginToolLoader
from langbot_plugin.api.entities.builtin.resource.tool import LLMTool
def make_tool(name: str) -> LLMTool:
return LLMTool(
name=name,
human_desc=name,
description=name,
parameters={'type': 'object', 'properties': {}},
func=lambda parameters: parameters,
)
@pytest.mark.asyncio
async def test_two_mcp_servers_with_same_tool_route_to_authorized_server():
tool = make_tool('shared_tool')
first = SimpleNamespace(
server_uuid='mcp-a',
get_tools=Mock(return_value=[tool]),
invoke_mcp_tool=AsyncMock(return_value='from-a'),
)
second = SimpleNamespace(
server_uuid='mcp-b',
get_tools=Mock(return_value=[tool]),
invoke_mcp_tool=AsyncMock(return_value='from-b'),
)
loader = MCPLoader(SimpleNamespace(logger=Mock()))
loader.sessions = {'first': first, 'second': second}
query = SimpleNamespace(variables={})
result = await loader.invoke_tool(
'shared_tool',
{'value': 1},
query,
source_id='mcp-b',
)
assert result == 'from-b'
first.invoke_mcp_tool.assert_not_awaited()
second.invoke_mcp_tool.assert_awaited_once_with(
'shared_tool',
{'value': 1},
query=query,
)
@pytest.mark.asyncio
@pytest.mark.parametrize('reserved_name', [MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE])
async def test_reserved_name_routes_to_real_mcp_tool_when_source_id_is_present(reserved_name):
real_tool = make_tool(reserved_name)
session = SimpleNamespace(
server_uuid='srv-real',
get_tools=Mock(return_value=[real_tool]),
invoke_mcp_tool=AsyncMock(return_value='real-result'),
)
loader = MCPLoader(SimpleNamespace(logger=Mock()))
loader.sessions = {'real': session}
query = SimpleNamespace(variables={})
assert await loader.has_tool(reserved_name, source_id='srv-real') is True
assert await loader.get_tool(reserved_name, source_id='srv-real') is real_tool
result = await loader.invoke_tool(reserved_name, {}, query, source_id='srv-real')
assert result == 'real-result'
session.invoke_mcp_tool.assert_awaited_once_with(reserved_name, {}, query=query)
@pytest.mark.asyncio
@pytest.mark.parametrize(
('reserved_name', 'invoke_method'),
[
(MCP_TOOL_LIST_RESOURCES, '_invoke_mcp_list_resources'),
(MCP_TOOL_READ_RESOURCE, '_invoke_mcp_read_resource'),
],
)
async def test_reserved_name_uses_host_synthetic_tool_when_source_id_is_none(
reserved_name,
invoke_method,
):
real_tool = make_tool(reserved_name)
session = SimpleNamespace(
server_uuid='srv-real',
server_name='real',
enable=True,
status=MCPSessionStatus.CONNECTED,
session=object(),
get_tools=Mock(return_value=[real_tool]),
has_resource_support=Mock(return_value=True),
invoke_mcp_tool=AsyncMock(return_value='real-result'),
)
loader = MCPLoader(SimpleNamespace(logger=Mock()))
loader.sessions = {'real': session}
synthetic_invoke = AsyncMock(return_value='synthetic-result')
setattr(loader, invoke_method, synthetic_invoke)
query = SimpleNamespace(variables={})
assert await loader.has_tool(reserved_name, source_id=None) is True
tool = await loader.get_tool(reserved_name, source_id=None)
result = await loader.invoke_tool(reserved_name, {}, query, source_id=None)
assert tool is not None
assert tool is not real_tool
assert result == 'synthetic-result'
synthetic_invoke.assert_awaited_once_with({}, query)
session.invoke_mcp_tool.assert_not_awaited()
@pytest.mark.asyncio
async def test_two_plugins_with_same_tool_forward_only_authorized_plugin():
manifest = SimpleNamespace(
owner='authorized/plugin',
metadata=SimpleNamespace(
name='shared_tool',
description=SimpleNamespace(en_US='Shared tool'),
),
spec={
'llm_prompt': 'Shared tool',
'parameters': {'type': 'object', 'properties': {}},
},
)
connector = SimpleNamespace(
list_tools=AsyncMock(return_value=[manifest]),
call_tool=AsyncMock(return_value='authorized-result'),
)
loader = PluginToolLoader(SimpleNamespace(plugin_connector=connector, logger=Mock()))
query = SimpleNamespace(session=SimpleNamespace(), query_id=7)
query.session.model_dump = Mock(return_value={})
result = await loader.invoke_tool(
'shared_tool',
{},
query,
source_id='authorized/plugin',
)
assert result == 'authorized-result'
connector.call_tool.assert_awaited_once_with(
'shared_tool',
{},
session=query.session,
query_id=7,
bound_plugins=['authorized/plugin'],
)
+22 -8
View File
@@ -102,7 +102,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1')
conversation = _make_conversation()
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
return SimpleNamespace(
sess_mgr=SimpleNamespace(
@@ -159,9 +159,10 @@ async def test_preproc_loads_host_tools_for_runner():
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@@ -172,10 +173,20 @@ async def test_preproc_puts_host_skill_tools_into_query_scope():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace())
app.tool_mgr.get_all_tools = AsyncMock(
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
SimpleNamespace(name='activate'),
SimpleNamespace(name='register_skill'),
{
'name': 'activate',
'source': 'skill',
'description': 'Activate a skill',
'parameters': {},
},
{
'name': 'register_skill',
'source': 'skill',
'description': 'Register a skill',
'parameters': {},
},
]
)
query = _make_query()
@@ -184,9 +195,10 @@ async def test_preproc_puts_host_skill_tools_into_query_scope():
result = await stage.process(query, 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
assert [tool.name for tool in query.use_funcs] == ['activate', 'register_skill']
@@ -203,9 +215,10 @@ async def test_preproc_loads_host_tools_regardless_of_skill_service():
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@@ -222,9 +235,10 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable
result = await stage.process(query, 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=False,
)
+12 -11
View File
@@ -56,7 +56,7 @@ class TestVectorDBManagerInitialization:
# Run initialize synchronously for test
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
# Chroma should be instantiated
mock_chroma_class.assert_called_once_with(mock_app)
@@ -78,7 +78,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_chroma_class.assert_called_once_with(mock_app)
mock_app.logger.info.assert_called()
@@ -99,7 +99,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_qdrant_class.assert_called_once_with(mock_app)
@@ -119,7 +119,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_seekdb_class.assert_called_once_with(mock_app)
@@ -138,7 +138,8 @@ class TestVectorDBManagerInitialization:
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_valkey_class.assert_called_once_with(mock_app)
@@ -161,7 +162,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_milvus_class.assert_called_once_with(
mock_app, uri='http://localhost:19530', token='root:Milvus', db_name='langbot_db'
@@ -183,7 +184,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
# Should use default values
mock_milvus_class.assert_called_once_with(mock_app, uri='./data/milvus.db', token=None, db_name='default')
@@ -204,7 +205,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, connection_string='postgresql://user:pass@host:5432/langbot'
@@ -235,7 +236,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret'
@@ -257,7 +258,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres'
@@ -279,7 +280,7 @@ class TestVectorDBManagerInitialization:
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
asyncio.run(mgr.initialize())
mock_chroma_class.assert_called_once_with(mock_app)
mock_app.logger.warning.assert_called()
@@ -357,10 +357,12 @@ class TestCredentialsBuild:
cred_calls.append(kwargs)
return ('CRED', kwargs)
monkeypatch.setattr(mod, 'GlideClient', _FakeClient)
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials)
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw)
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k))
# The optional glide import defines none of these names when the
# dependency is absent, which is the normal fast-test environment.
monkeypatch.setattr(mod, 'GlideClient', _FakeClient, raising=False)
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials, raising=False)
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw, raising=False)
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k), raising=False)
return backend, created, cred_calls, warnings
async def test_username_without_password_fails_closed(self, monkeypatch):
+1 -1
View File
@@ -115,7 +115,7 @@ class TestVectorDatabaseAbstractMethods:
# list_by_filter should return empty list and -1 for total
import asyncio
result = asyncio.get_event_loop().run_until_complete(db.list_by_filter('test_collection'))
result = asyncio.run(db.list_by_filter('test_collection'))
assert result == ([], -1)