mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 09:56:06 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -9,34 +9,60 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
import json
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
run_alembic_upgrade,
|
||||
run_alembic_stamp,
|
||||
get_alembic_current,
|
||||
_ALEMBIC_DIR,
|
||||
)
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent as agent_models,
|
||||
agent_run as agent_run_models,
|
||||
agent_runner_state as agent_runner_state_models,
|
||||
bot as bot_models,
|
||||
metadata as metadata_models,
|
||||
monitoring as monitoring_models,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
_ALEMBIC_DIR,
|
||||
get_alembic_current,
|
||||
run_alembic_stamp,
|
||||
run_alembic_upgrade,
|
||||
)
|
||||
|
||||
|
||||
def _get_script_directory() -> ScriptDirectory:
|
||||
"""Load the repository's Alembic revision graph."""
|
||||
cfg = Config()
|
||||
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||
return ScriptDirectory.from_config(cfg)
|
||||
|
||||
|
||||
def _get_script_head() -> str:
|
||||
"""Resolve the current Alembic head revision from the script directory.
|
||||
|
||||
Avoids hardcoding a revision number in assertions so adding a new
|
||||
migration doesn't require editing the migration tests.
|
||||
"""
|
||||
cfg = Config()
|
||||
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
||||
"""Resolve the only Alembic head without hardcoding a revision."""
|
||||
return _get_script_directory().get_current_head()
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestAlembicRevisionGraph:
|
||||
"""Static release gates for the Alembic graph."""
|
||||
|
||||
def test_revision_ids_fit_alembic_version_column_and_graph_has_one_head(self):
|
||||
script = _get_script_directory()
|
||||
revisions = list(script.walk_revisions())
|
||||
|
||||
assert script.get_bases() == ['0001_baseline']
|
||||
assert script.get_heads() == ['0012_monitoring_tool_calls']
|
||||
assert all(len(item.revision) <= 32 for item in revisions), {
|
||||
item.revision: len(item.revision) for item in revisions if len(item.revision) > 32
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_db_url(tmp_path):
|
||||
"""Create SQLite URL with temporary database file."""
|
||||
@@ -149,6 +175,174 @@ class TestSQLiteMigrationUpgrade:
|
||||
rev2 = await get_alembic_current(sqlite_engine)
|
||||
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_mcp_resource_branch_creates_agent_and_monitoring_schema(self, sqlite_engine):
|
||||
"""The MCP branch can converge into the Agent branch and the current head."""
|
||||
await run_alembic_stamp(sqlite_engine, '0008_mcp_resource_prefs')
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
def inspect_schema(sync_conn):
|
||||
inspector = sa.inspect(sync_conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
monitoring_indexes = {
|
||||
index['name'] for index in inspector.get_indexes(monitoring_models.MonitoringToolCall.__tablename__)
|
||||
}
|
||||
return tables, monitoring_indexes
|
||||
|
||||
async with sqlite_engine.connect() as conn:
|
||||
tables, monitoring_indexes = await conn.run_sync(inspect_schema)
|
||||
|
||||
expected_agent_tables = {
|
||||
agent_models.Agent.__tablename__,
|
||||
agent_run_models.AgentRun.__tablename__,
|
||||
agent_run_models.AgentRunEvent.__tablename__,
|
||||
agent_run_models.AgentRuntime.__tablename__,
|
||||
agent_runner_state_models.AgentRunnerState.__tablename__,
|
||||
}
|
||||
expected_monitoring_indexes = {index.name for index in monitoring_models.MonitoringToolCall.__table__.indexes}
|
||||
|
||||
assert expected_agent_tables <= tables
|
||||
assert monitoring_models.MonitoringToolCall.__tablename__ in tables
|
||||
assert expected_monitoring_indexes <= monitoring_indexes
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_admin_data_migrates_when_create_all_already_created_table(self, sqlite_engine):
|
||||
"""0007 must migrate config admins even when the ORM created its table first."""
|
||||
config = {
|
||||
'admins': ['group_admin-1', 'person_user_with_underscore', 'malformed'],
|
||||
'preserved': True,
|
||||
}
|
||||
|
||||
async with sqlite_engine.begin() as conn:
|
||||
await conn.run_sync(bot_models.Bot.__table__.create)
|
||||
await conn.run_sync(bot_models.BotAdmin.__table__.create)
|
||||
await conn.run_sync(metadata_models.Metadata.__table__.create)
|
||||
await conn.execute(
|
||||
sa.insert(bot_models.Bot).values(
|
||||
uuid='bot-1',
|
||||
name='Bot',
|
||||
description='',
|
||||
adapter='test',
|
||||
adapter_config={},
|
||||
enable=True,
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(bot_models.BotAdmin).values(
|
||||
bot_uuid='bot-1',
|
||||
launcher_type='group',
|
||||
launcher_id='admin-1',
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(metadata_models.Metadata).values(
|
||||
key='instance_config',
|
||||
value=json.dumps(config),
|
||||
)
|
||||
)
|
||||
|
||||
await run_alembic_stamp(sqlite_engine, '0006_normalize_mcp_remote_mode')
|
||||
await run_alembic_upgrade(sqlite_engine, '0007_add_bot_admins')
|
||||
|
||||
async with sqlite_engine.connect() as conn:
|
||||
admin_rows = (
|
||||
await conn.execute(
|
||||
sa.select(
|
||||
bot_models.BotAdmin.launcher_type,
|
||||
bot_models.BotAdmin.launcher_id,
|
||||
).order_by(bot_models.BotAdmin.launcher_type, bot_models.BotAdmin.launcher_id)
|
||||
)
|
||||
).all()
|
||||
stored_config = (
|
||||
await conn.execute(
|
||||
sa.select(metadata_models.Metadata.value).where(metadata_models.Metadata.key == 'instance_config')
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
assert admin_rows == [('group', 'admin-1'), ('person', 'user_with_underscore')]
|
||||
assert json.loads(stored_config) == {'preserved': True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_routing_rules_preserve_message_filters(self, sqlite_engine):
|
||||
"""Every legacy routing rule keeps its matching semantics in event bindings."""
|
||||
routing_rules = [
|
||||
{
|
||||
'type': 'launcher_type',
|
||||
'operator': 'eq',
|
||||
'value': 'group',
|
||||
'pipeline_uuid': 'pipeline-group',
|
||||
},
|
||||
{
|
||||
'type': 'launcher_id',
|
||||
'operator': 'regex',
|
||||
'value': '^room-',
|
||||
'pipeline_uuid': 'pipeline-room',
|
||||
},
|
||||
{
|
||||
'type': 'message_content',
|
||||
'operator': 'contains',
|
||||
'value': 'urgent',
|
||||
'pipeline_uuid': 'pipeline-content',
|
||||
},
|
||||
{
|
||||
'type': 'message_has_element',
|
||||
'operator': 'eq',
|
||||
'value': 'Image',
|
||||
'pipeline_uuid': 'pipeline-image',
|
||||
},
|
||||
{
|
||||
'type': 'message_has_element',
|
||||
'operator': 'neq',
|
||||
'value': 'Voice',
|
||||
'pipeline_uuid': 'pipeline-no-voice',
|
||||
},
|
||||
]
|
||||
|
||||
async with sqlite_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'CREATE TABLE bots ('
|
||||
'uuid VARCHAR(255) PRIMARY KEY, '
|
||||
'use_pipeline_uuid VARCHAR(255), '
|
||||
'pipeline_routing_rules JSON NOT NULL, '
|
||||
'event_bindings JSON NOT NULL'
|
||||
')'
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO bots '
|
||||
'(uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings) '
|
||||
'VALUES (:uuid, :default_pipeline, :rules, :bindings)'
|
||||
),
|
||||
{
|
||||
'uuid': 'bot-routing',
|
||||
'default_pipeline': 'pipeline-default',
|
||||
'rules': json.dumps(routing_rules),
|
||||
'bindings': '[]',
|
||||
},
|
||||
)
|
||||
|
||||
await run_alembic_stamp(sqlite_engine, '0008_agent_product_surface')
|
||||
await run_alembic_upgrade(sqlite_engine, '0009_migrate_event_bindings')
|
||||
|
||||
async with sqlite_engine.connect() as conn:
|
||||
raw_bindings = (
|
||||
await conn.execute(sa.text("SELECT event_bindings FROM bots WHERE uuid = 'bot-routing'"))
|
||||
).scalar_one()
|
||||
|
||||
bindings = json.loads(raw_bindings)
|
||||
filters_by_pipeline = {binding['target_uuid']: binding['filters'] for binding in bindings}
|
||||
assert filters_by_pipeline == {
|
||||
'pipeline-group': [{'field': 'chat_type', 'operator': 'eq', 'value': 'group'}],
|
||||
'pipeline-room': [{'field': 'chat_id', 'operator': 'regex', 'value': '^room-'}],
|
||||
'pipeline-content': [{'field': 'message_text', 'operator': 'contains', 'value': 'urgent'}],
|
||||
'pipeline-image': [{'field': 'message_element_types', 'operator': 'contains', 'value': 'Image'}],
|
||||
'pipeline-no-voice': [{'field': 'message_element_types', 'operator': 'not_contains', 'value': 'Voice'}],
|
||||
'pipeline-default': [],
|
||||
}
|
||||
|
||||
|
||||
class TestSQLiteMigrationFreshDatabase:
|
||||
"""Tests for fresh database workflow."""
|
||||
|
||||
@@ -31,7 +31,7 @@ def mock_circular_import_chain():
|
||||
"""
|
||||
Break circular import chain for pipeline modules using isolated_sys_modules.
|
||||
|
||||
Chain: pipeline → core.app → provider.runner → http_controller → groups/plugins
|
||||
Chain: pipeline → core.app → http_controller → groups/plugins
|
||||
|
||||
We mock minimal modules to allow importing RuntimePipeline, StageInstContainer,
|
||||
and stage classes without triggering full application initialization.
|
||||
@@ -47,7 +47,7 @@ def mock_circular_import_chain():
|
||||
# Mock core.app - Application class is referenced but not instantiated
|
||||
mock_core_app = Mock()
|
||||
|
||||
# Mock utils.importutil - prevents auto-import of runners
|
||||
# Mock utils.importutil to avoid unrelated import-time registrations.
|
||||
mock_importutil = Mock()
|
||||
mock_importutil.import_modules_in_pkg = lambda pkg: None
|
||||
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
|
||||
@@ -63,14 +63,13 @@ def mock_circular_import_chain():
|
||||
'langbot.pkg.pipeline.process.handlers.chat',
|
||||
'langbot.pkg.pipeline.process.handlers.command',
|
||||
'langbot.pkg.pipeline.respback.respback',
|
||||
'langbot.pkg.provider.runner',
|
||||
]
|
||||
|
||||
with isolated_sys_modules(
|
||||
mocks={
|
||||
'langbot.pkg.core.entities': mock_core_entities,
|
||||
'langbot.pkg.core.app': mock_core_app,
|
||||
'langbot.pkg.utils.importutil': mock_importutil,
|
||||
'langbot.pkg.utils.importutil': mock_importutil,
|
||||
'langbot.pkg.pipeline.controller': Mock(),
|
||||
'langbot.pkg.pipeline.pipelinemgr': Mock(),
|
||||
},
|
||||
@@ -249,9 +248,7 @@ def set_fake_runner(pipeline_app):
|
||||
yield result
|
||||
|
||||
orchestrator.run_from_query = run_from_query
|
||||
orchestrator.resolve_runner_id_for_telemetry = Mock(
|
||||
return_value='plugin:langbot-team/LocalAgent/default'
|
||||
)
|
||||
orchestrator.resolve_runner_id_for_telemetry = Mock(return_value='plugin:langbot-team/LocalAgent/default')
|
||||
pipeline_app.agent_run_orchestrator = orchestrator
|
||||
|
||||
return _set_runner
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+6
-6
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]:
|
||||
'langbot.pkg.pipeline.controller': MagicMock(),
|
||||
'langbot.pkg.pipeline.pipelinemgr': MagicMock(),
|
||||
'langbot.pkg.pipeline.process.process': MagicMock(),
|
||||
'langbot.pkg.provider.runner': MagicMock(preregistered_runners=[]),
|
||||
'langbot.pkg.utils.importutil': mock_importutil,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user