mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-26 19:36:35 +08:00
244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""Real scoped database checks against the reviewed LocalAgent descriptor."""
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace as NS
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
|
|
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
|
|
from langbot.pkg.entity.persistence.rag import KnowledgeBase
|
|
from langbot.pkg.entity.persistence.mcp import MCPServer
|
|
from tests.unit_tests.api.service import test_pipeline_migration as base
|
|
from tests.unit_tests.api.service.test_pipeline_migration import context, WS, OTHER
|
|
|
|
env = base.env
|
|
|
|
|
|
@pytest.fixture
|
|
async def local(env):
|
|
spec = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_local_schema.json').read_text())
|
|
rid = 'plugin:langbot-team/LocalAgent/default'
|
|
descriptor = RunnerDescriptor(
|
|
id=rid,
|
|
source='plugin',
|
|
label={},
|
|
plugin_author='langbot-team',
|
|
plugin_name='LocalAgent',
|
|
runner_name='default',
|
|
plugin_version='reviewed',
|
|
usages=['agent'],
|
|
config_schema=spec['config'],
|
|
capabilities=spec['capabilities'],
|
|
permissions=spec['permissions'],
|
|
)
|
|
env.ap.runner_registry.list_runners.return_value = [descriptor]
|
|
env.ap.logger = Mock()
|
|
env.ap.tool_mgr = NS(
|
|
get_resolved_tool_catalog=AsyncMock(
|
|
return_value=[{'name': 'scoped_tool', 'source': 'mcp', 'source_id': 'mcp'}]
|
|
),
|
|
get_tool_schema=AsyncMock(return_value=('', {})),
|
|
)
|
|
async with env.engine.begin() as conn:
|
|
await conn.run_sync(
|
|
lambda c: ModelProvider.metadata.create_all(
|
|
c, tables=[ModelProvider.__table__, LLMModel.__table__, KnowledgeBase.__table__, MCPServer.__table__]
|
|
)
|
|
)
|
|
for ws, suffix in [(WS, ''), (OTHER, '_foreign')]:
|
|
await conn.execute(
|
|
sa.insert(ModelProvider).values(
|
|
uuid='provider' + suffix,
|
|
workspace_uuid=ws,
|
|
name='provider',
|
|
requester='test',
|
|
base_url='https://synthetic.invalid',
|
|
)
|
|
)
|
|
await conn.execute(
|
|
sa.insert(LLMModel).values(
|
|
uuid='model' + suffix, workspace_uuid=ws, name='model', provider_uuid='provider' + suffix
|
|
)
|
|
)
|
|
await conn.execute(sa.insert(KnowledgeBase).values(uuid='kb' + suffix, workspace_uuid=ws, name='kb'))
|
|
await conn.execute(
|
|
sa.insert(MCPServer).values(
|
|
uuid='mcp' + suffix, workspace_uuid=ws, name='mcp' + suffix, mode='remote', enable=True
|
|
)
|
|
)
|
|
config = {item['name']: copy.deepcopy(item['default']) for item in spec['config'] if 'default' in item}
|
|
config.update(
|
|
{
|
|
'model': {'primary': 'model', 'fallbacks': [], 'reasoning': {}},
|
|
'knowledge-bases': ['kb'],
|
|
'tools': ['scoped_tool'],
|
|
'enable-all-tools': False,
|
|
'mcp-resources': [{'server_uuid': 'mcp', 'uri': 'test://document', 'enabled': True}],
|
|
'mcp-resource-agent-read-enabled': True,
|
|
}
|
|
)
|
|
plan = {
|
|
'target_runner_id': rid,
|
|
'target_plugin': {'version': 'reviewed'},
|
|
'config': {'ai': {'runner': {'id': rid}, 'runner_config': {rid: config}}},
|
|
}
|
|
return env, descriptor, config, plan
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_local_resources_and_real_descriptor_options(local):
|
|
env, _, _, plan = local
|
|
async with env.pm.tenant_scope(WS):
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
|
env.ap.tool_mgr.get_resolved_tool_catalog.assert_awaited_once()
|
|
ctx = env.ap.tool_mgr.get_resolved_tool_catalog.call_args.args[0]
|
|
assert ctx.workspace_uuid == WS
|
|
assert env.pm.current_session() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool'])
|
|
async def test_foreign_or_unresolved_resource_is_denied(local, resource):
|
|
env, _, config, plan = local
|
|
if resource == 'model':
|
|
config['model']['primary'] = 'model_foreign'
|
|
elif resource == 'kb':
|
|
config['knowledge-bases'] = ['kb_foreign']
|
|
elif resource == 'mcp':
|
|
config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign'
|
|
else:
|
|
config['tools'] = ['foreign_tool']
|
|
async with env.pm.tenant_scope(WS):
|
|
with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'):
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('declared', [False, True])
|
|
async def test_null_requires_explicit_descriptor_nullable(local, declared):
|
|
env, descriptor, config, plan = local
|
|
config['timeout'] = None
|
|
field = next(f for f in descriptor.config_schema if f['name'] == 'timeout')
|
|
field['nullable'] = declared
|
|
async with env.pm.tenant_scope(WS):
|
|
if declared:
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
|
else:
|
|
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_old_plugin_version_is_rejected_before_resource_resolution(local):
|
|
env, descriptor, _, plan = local
|
|
descriptor.plugin_version = 'old'
|
|
async with env.pm.tenant_scope(WS):
|
|
with pytest.raises(env.m.MigrationError, match='plugin_version_incompatible'):
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|
|
env.ap.tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_descriptor_migrates_through_detached_task(local, monkeypatch):
|
|
env, _, _, plan = local
|
|
|
|
def planner(config, extensions_preferences=None):
|
|
result = base.planner(config, extensions_preferences)
|
|
if result['state'] == 'ready':
|
|
result.update(copy.deepcopy(plan))
|
|
result['target_plugin'].update(author='langbot-team', name='LocalAgent')
|
|
return result
|
|
|
|
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', planner)
|
|
async with env.engine.begin() as conn:
|
|
await conn.execute(
|
|
sa.insert(base.PluginSetting).values(
|
|
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='LocalAgent', enabled=True
|
|
)
|
|
)
|
|
task = await base.execute(env)
|
|
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
|
|
configs, snapshots = await base.rows(env)
|
|
assert configs['one'] == plan['config']
|
|
assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE
|
|
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'template',
|
|
[
|
|
None,
|
|
'{global}',
|
|
'{launcher_type}_{launcher_id}',
|
|
'{launcher_type}_{launcher_id}_{sender_id}',
|
|
'{launcher_type}_{launcher_id}_{conversation_id}',
|
|
'{query_id}',
|
|
'{bot_id}_{launcher_type}_{launcher_id}',
|
|
'project-{project}',
|
|
],
|
|
)
|
|
@pytest.mark.parametrize('install_plugins', [False, True])
|
|
async def test_box_templates_roundtrip_in_both_migration_modes(local, monkeypatch, template, install_plugins):
|
|
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
|
|
|
|
env, descriptor, _, _ = local
|
|
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
|
|
section = {'model': 'model', 'prompt': [], 'enable-all-tools': False, 'tools': []}
|
|
if template is not None:
|
|
section['box-session-id-template'] = template
|
|
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': section}}
|
|
plan = plan_legacy_pipeline(source)
|
|
descriptor.plugin_version = plan['target_plugin']['version']
|
|
async with env.engine.begin() as conn:
|
|
await conn.execute(
|
|
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
|
|
)
|
|
await conn.execute(sa.delete(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'two'))
|
|
if install_plugins:
|
|
await conn.execute(
|
|
sa.insert(base.PluginSetting).values(
|
|
workspace_uuid=WS,
|
|
plugin_author='langbot-team',
|
|
plugin_name='LocalAgent',
|
|
enabled=True,
|
|
)
|
|
)
|
|
if not install_plugins:
|
|
env.ap.runner_registry.list_runners.side_effect = AssertionError('Data-only must work offline')
|
|
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('Data-only must not install'))
|
|
_, result = await base.execute_all(env, install_plugins=install_plugins)
|
|
assert result['results'] == [
|
|
{
|
|
'pipeline_uuid': 'one',
|
|
'state': 'migrated',
|
|
'code': None if install_plugins else 'data_only',
|
|
}
|
|
]
|
|
configs, snapshots = await base.rows(env)
|
|
saved = configs['one']['ai']['runner_config'][plan['target_runner_id']]
|
|
effective = {field['name']: field['default'] for field in descriptor.config_schema if 'default' in field}
|
|
effective.update(saved)
|
|
assert effective['box-enabled'] is True
|
|
assert effective['box-session-id-template'] == (template or '{launcher_type}_{launcher_id}')
|
|
assert snapshots[0]['source_snapshot']['config'] == source
|
|
assert 'box-session-id-template' not in configs['one']['ai']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('allow_custom', [False, None, 'true'])
|
|
async def test_select_requires_explicit_custom_support(local, allow_custom):
|
|
env, descriptor, config, plan = local
|
|
field = next(f for f in descriptor.config_schema if f['name'] == 'box-session-id-template')
|
|
field['allow_custom'] = allow_custom
|
|
config['box-session-id-template'] = '{bot_id}_{launcher_id}'
|
|
async with env.pm.tenant_scope(WS):
|
|
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
|
|
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
|