mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-15 00:46:07 +00:00
Merge remote-tracking branch 'origin/master' into feat/card_dify_human_input
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
|
||||
### 4. 向量数据库 (`vector/vdbs/`)
|
||||
- **路径**: `src/langbot/pkg/vector/vdbs/`
|
||||
- **模块**: chroma, milvus, pgvector, qdrant, seekdb
|
||||
- **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search
|
||||
- **排除原因**: 需要真实向量数据库实例运行
|
||||
- **测试方式**: 需要 Docker 启动测试数据库或 mock
|
||||
- **状态**: 后续可补充 mock 测试
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Unit tests for LangBot API HTTP service layer."""
|
||||
"""Unit tests for LangBot API HTTP service layer."""
|
||||
|
||||
@@ -13,4 +13,4 @@ Does NOT:
|
||||
- Call real provider/platform/network
|
||||
|
||||
Uses tests.factories.FakeApp as base mock application.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -132,9 +132,7 @@ class TestApiKeyServiceCreateApiKey:
|
||||
with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'):
|
||||
result = await service.create_api_key('New Key', 'Test description')
|
||||
|
||||
assert insert_params == [
|
||||
{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}
|
||||
]
|
||||
assert insert_params == [{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}]
|
||||
assert result['key'].startswith('lbk_')
|
||||
assert result['key'] == 'lbk_fixed-token'
|
||||
assert result['name'] == 'New Key'
|
||||
@@ -257,16 +255,22 @@ class TestApiKeyServiceGetApiKey:
|
||||
class TestApiKeyServiceVerifyApiKey:
|
||||
"""Tests for verify_api_key method."""
|
||||
|
||||
@staticmethod
|
||||
def _make_ap(db_key=None, global_api_key=''):
|
||||
"""Build a mock Application with persistence + instance_config."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=db_key)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}})
|
||||
return ap
|
||||
|
||||
async def test_verify_api_key_valid(self):
|
||||
"""Returns True for valid API key."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
key = Mock(spec=ApiKey)
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=key)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap = self._make_ap(db_key=key)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
@@ -279,12 +283,7 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
async def test_verify_api_key_invalid(self):
|
||||
"""Returns False for invalid API key."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap = self._make_ap(db_key=None)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
@@ -297,12 +296,7 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
async def test_verify_api_key_empty_string(self):
|
||||
"""Returns False for empty key string."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap = self._make_ap(db_key=None)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
@@ -315,12 +309,7 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
async def test_verify_api_key_unknown_key(self):
|
||||
"""Returns False when the key is not present in persistence."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap = self._make_ap(db_key=None)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
@@ -330,6 +319,70 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
async def test_verify_global_api_key_match(self):
|
||||
"""Returns True when key matches the config.yaml global API key (no DB lookup)."""
|
||||
# Setup: no DB record, but a global key is configured
|
||||
ap = self._make_ap(db_key=None, global_api_key='my-global-secret')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('my-global-secret')
|
||||
|
||||
# Verify: accepted purely on config match
|
||||
assert result is True
|
||||
# DB should not have been consulted for the global-key path
|
||||
ap.persistence_mgr.execute_async.assert_not_called()
|
||||
|
||||
async def test_verify_global_api_key_no_prefix_required(self):
|
||||
"""Global API key is accepted even without the lbk_ prefix."""
|
||||
ap = self._make_ap(db_key=None, global_api_key='plainsecret123')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('plainsecret123')
|
||||
|
||||
assert result is True
|
||||
|
||||
async def test_verify_global_api_key_mismatch_falls_back_to_db(self):
|
||||
"""A non-matching key still falls through to the DB lookup."""
|
||||
# Global key set, but request uses a different lbk_ key that IS in DB
|
||||
key = Mock(spec=ApiKey)
|
||||
ap = self._make_ap(db_key=key, global_api_key='my-global-secret')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('lbk_db_key')
|
||||
|
||||
assert result is True
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_verify_empty_global_api_key_disabled(self):
|
||||
"""An empty global_api_key must never authenticate an empty/blank request."""
|
||||
ap = self._make_ap(db_key=None, global_api_key='')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Empty request key is rejected, and a blank global key never matches
|
||||
assert await service.verify_api_key('') is False
|
||||
assert await service.verify_api_key(' ') is False
|
||||
|
||||
async def test_verify_api_key_missing_global_config_key(self):
|
||||
"""Works even when api.global_api_key is absent (existing installs)."""
|
||||
# instance_config without the global_api_key field at all
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.instance_config = SimpleNamespace(data={'api': {}})
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('lbk_some_key')
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestApiKeyServiceDeleteApiKey:
|
||||
"""Tests for delete_api_key method."""
|
||||
|
||||
@@ -303,13 +303,7 @@ class TestBotServiceCreateBot:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {
|
||||
'system': {
|
||||
'limitation': {
|
||||
'max_bots': 2
|
||||
}
|
||||
}
|
||||
}
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_bots': 2}}}
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.load_bot = AsyncMock()
|
||||
|
||||
@@ -318,9 +312,7 @@ class TestBotServiceCreateBot:
|
||||
bot2 = _create_mock_bot(bot_uuid='uuid-2')
|
||||
mock_result = _create_mock_result([bot1, bot2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'uuid-1', 'name': 'Bot 1'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'})
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
@@ -352,6 +344,7 @@ class TestBotServiceCreateBot:
|
||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -362,9 +355,7 @@ class TestBotServiceCreateBot:
|
||||
return bot_result # Get bot
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'new-uuid', 'name': 'New Bot'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
@@ -397,6 +388,7 @@ class TestBotServiceCreateBot:
|
||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -492,6 +484,7 @@ class TestBotServiceUpdateBot:
|
||||
pipeline_result.first = Mock(return_value=mock_pipeline)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -582,10 +575,9 @@ class TestBotServiceListEventLogs:
|
||||
# Mock runtime bot with logger
|
||||
runtime_bot = SimpleNamespace()
|
||||
runtime_bot.logger = SimpleNamespace()
|
||||
runtime_bot.logger.get_logs = AsyncMock(return_value=(
|
||||
[SimpleNamespace(to_json=Mock(return_value={'msg': 'log1'}))],
|
||||
5
|
||||
))
|
||||
runtime_bot.logger.get_logs = AsyncMock(
|
||||
return_value=([SimpleNamespace(to_json=Mock(return_value={'msg': 'log1'}))], 5)
|
||||
)
|
||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
|
||||
|
||||
service = BotService(ap)
|
||||
@@ -646,11 +638,7 @@ class TestBotServiceSendMessage:
|
||||
service = BotService(ap)
|
||||
|
||||
# Execute with valid message chain format
|
||||
message_chain_data = {
|
||||
'messages': [
|
||||
{'type': 'text', 'data': {'text': 'Hello'}}
|
||||
]
|
||||
}
|
||||
message_chain_data = {'messages': [{'type': 'text', 'data': {'text': 'Hello'}}]}
|
||||
|
||||
# Patch the import location - the module imports inside the function
|
||||
with patch('langbot_plugin.api.entities.builtin.platform.message.MessageChain') as MockMessageChain:
|
||||
|
||||
@@ -6,6 +6,7 @@ Tests cover:
|
||||
- Knowledge engine discovery
|
||||
- File operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -52,9 +53,7 @@ class TestGetKnowledgeBases:
|
||||
"""Test that it returns all knowledge base details."""
|
||||
knowledge_module = get_knowledge_service_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(
|
||||
return_value=[{'uuid': 'kb1', 'name': 'KB1'}]
|
||||
)
|
||||
mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(return_value=[{'uuid': 'kb1', 'name': 'KB1'}])
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
result = await service.get_knowledge_bases()
|
||||
@@ -83,9 +82,7 @@ class TestGetKnowledgeBase:
|
||||
"""Test that it returns specific KB details."""
|
||||
knowledge_module = get_knowledge_service_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
|
||||
return_value={'uuid': 'kb1', 'name': 'KB1'}
|
||||
)
|
||||
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'KB1'})
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
result = await service.get_knowledge_base('kb1')
|
||||
@@ -153,9 +150,7 @@ class TestCreateKnowledgeBase:
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
|
||||
await service.create_knowledge_base({
|
||||
'knowledge_engine_plugin_id': 'author/engine'
|
||||
})
|
||||
await service.create_knowledge_base({'knowledge_engine_plugin_id': 'author/engine'})
|
||||
|
||||
# Check that default name 'Untitled' was used
|
||||
call_args = mock_app.rag_mgr.create_knowledge_base.call_args
|
||||
@@ -170,20 +165,21 @@ class TestUpdateKnowledgeBase:
|
||||
"""Test that only mutable fields are updated."""
|
||||
knowledge_module = get_knowledge_service_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
|
||||
return_value={'uuid': 'kb1', 'name': 'Updated'}
|
||||
)
|
||||
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'Updated'})
|
||||
mock_app.rag_mgr.remove_knowledge_base_from_runtime = AsyncMock()
|
||||
mock_app.rag_mgr.load_knowledge_base = AsyncMock()
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
|
||||
# Pass both mutable and immutable fields
|
||||
await service.update_knowledge_base('kb1', {
|
||||
'name': 'New Name',
|
||||
'description': 'New desc',
|
||||
'uuid': 'should_be_filtered', # immutable
|
||||
})
|
||||
await service.update_knowledge_base(
|
||||
'kb1',
|
||||
{
|
||||
'name': 'New Name',
|
||||
'description': 'New desc',
|
||||
'uuid': 'should_be_filtered', # immutable
|
||||
},
|
||||
)
|
||||
|
||||
# Check that only mutable fields were passed to update
|
||||
call_args = mock_app.persistence_mgr.execute_async.call_args
|
||||
@@ -288,9 +284,7 @@ class TestListKnowledgeEngines:
|
||||
"""Test that it returns empty list and logs warning on exception."""
|
||||
knowledge_module = get_knowledge_service_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
|
||||
side_effect=Exception('Connection error')
|
||||
)
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(side_effect=Exception('Connection error'))
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
result = await service.list_knowledge_engines()
|
||||
@@ -386,12 +380,10 @@ class TestGetEngineSchemas:
|
||||
"""Test that it returns empty dict and logs warning on exception."""
|
||||
knowledge_module = get_knowledge_service_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(
|
||||
side_effect=Exception('Plugin error')
|
||||
)
|
||||
mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(side_effect=Exception('Plugin error'))
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
result = await service.get_engine_creation_schema('author/engine')
|
||||
|
||||
assert result == {}
|
||||
mock_app.logger.warning.assert_called_once()
|
||||
mock_app.logger.warning.assert_called_once()
|
||||
|
||||
@@ -174,9 +174,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {
|
||||
'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}
|
||||
}
|
||||
ap.instance_config.data = {'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}}
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.logger = SimpleNamespace()
|
||||
ap.logger.warning = Mock()
|
||||
@@ -292,12 +290,8 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
service._file_count = Mock(return_value=0)
|
||||
service._monitoring_counts = AsyncMock(return_value={})
|
||||
service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': 0})
|
||||
service._expired_uploaded_candidates = AsyncMock(return_value=[
|
||||
{'key': 'old_file', 'size_bytes': 100}
|
||||
])
|
||||
service._expired_log_candidates = Mock(return_value=[
|
||||
{'name': 'old_log', 'size_bytes': 50}
|
||||
])
|
||||
service._expired_uploaded_candidates = AsyncMock(return_value=[{'key': 'old_file', 'size_bytes': 100}])
|
||||
service._expired_log_candidates = Mock(return_value=[{'name': 'old_log', 'size_bytes': 50}])
|
||||
|
||||
# Execute
|
||||
result = await service.get_storage_analysis()
|
||||
@@ -367,6 +361,7 @@ class TestMaintenanceServiceBinaryStorageStats:
|
||||
size_result = _create_mock_result(scalar_value=5000)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -396,6 +391,7 @@ class TestMaintenanceServiceBinaryStorageStats:
|
||||
count_result = _create_mock_result(scalar_value=5)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -821,4 +817,4 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
result = service._expired_local_upload_candidates(7, include_paths=True)
|
||||
|
||||
# Verify - path included
|
||||
assert 'path' in result[0]
|
||||
assert 'path' in result[0]
|
||||
|
||||
@@ -90,6 +90,56 @@ class TestMCPServiceGetRuntimeInfo:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestMCPServiceResources:
|
||||
"""Tests for MCP resource helpers."""
|
||||
|
||||
async def test_get_resource_templates_delegates_to_loader(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.tool_mgr = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock(
|
||||
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
|
||||
)
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
result = await service.get_mcp_server_resource_templates('docs')
|
||||
|
||||
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
|
||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
|
||||
|
||||
async def test_read_resource_envelope_uses_ui_preview_source(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.tool_mgr = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock(
|
||||
return_value={
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'contents': [],
|
||||
'source': 'ui_preview',
|
||||
}
|
||||
)
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
result = await service.read_mcp_server_resource_envelope(
|
||||
'docs',
|
||||
'file:///README.md',
|
||||
max_bytes=4096,
|
||||
include_blob=True,
|
||||
)
|
||||
|
||||
assert result['source'] == 'ui_preview'
|
||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
|
||||
'docs',
|
||||
'file:///README.md',
|
||||
include_blob=True,
|
||||
source='ui_preview',
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
|
||||
class TestMCPServiceGetMCPServers:
|
||||
"""Tests for get_mcp_servers method."""
|
||||
|
||||
@@ -186,13 +236,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {
|
||||
'system': {
|
||||
'limitation': {
|
||||
'max_extensions': 2
|
||||
}
|
||||
}
|
||||
}
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_extensions': 2}}}
|
||||
ap.plugin_connector = SimpleNamespace()
|
||||
ap.plugin_connector.list_plugins = AsyncMock(return_value=[Mock(), Mock()]) # 2 plugins
|
||||
|
||||
@@ -236,6 +280,25 @@ class TestMCPServiceCreateMCPServer:
|
||||
assert server_uuid is not None
|
||||
assert len(server_uuid) == 36 # UUID format
|
||||
|
||||
async def test_create_mcp_server_duplicate_name_raises(self):
|
||||
"""Rejects duplicate MCP server names."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
|
||||
ap.tool_mgr = None
|
||||
|
||||
existing_server = _create_mock_mcp_server(name='Existing Server')
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
|
||||
await service.create_mcp_server({'name': 'Existing Server'})
|
||||
|
||||
async def test_create_mcp_server_loads_server(self):
|
||||
"""Loads server into tool_mgr when enabled."""
|
||||
# Setup
|
||||
@@ -252,11 +315,12 @@ class TestMCPServiceCreateMCPServer:
|
||||
server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return _create_mock_result([]) # Empty list for limit check
|
||||
return _create_mock_result([]) # Empty result for duplicate-name check
|
||||
elif call_count == 2:
|
||||
return Mock() # Insert
|
||||
return _create_mock_result(first_item=server_entity) # Select created
|
||||
@@ -361,6 +425,7 @@ class TestMCPServiceUpdateMCPServer:
|
||||
old_server = _create_mock_mcp_server(name='Old Server', enable=True)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -394,6 +459,7 @@ class TestMCPServiceUpdateMCPServer:
|
||||
updated_server = _create_mock_mcp_server(name='Old Server', enable=True)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -432,6 +498,7 @@ class TestMCPServiceUpdateMCPServer:
|
||||
|
||||
# Mock for: first select -> update -> second select (for updated server)
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -465,6 +532,7 @@ class TestMCPServiceUpdateMCPServer:
|
||||
|
||||
# Mock execute for select and update
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -499,6 +567,7 @@ class TestMCPServiceDeleteMCPServer:
|
||||
server = _create_mock_mcp_server(name='Server to Delete')
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -530,6 +599,7 @@ class TestMCPServiceDeleteMCPServer:
|
||||
server = _create_mock_mcp_server(name='Not in Sessions')
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -559,6 +629,7 @@ class TestMCPServiceDeleteMCPServer:
|
||||
|
||||
# No server found
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -596,9 +667,7 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
|
||||
|
||||
ap.task_mgr = SimpleNamespace()
|
||||
ap.task_mgr.create_user_task = Mock(
|
||||
return_value=SimpleNamespace(id=123)
|
||||
)
|
||||
ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=123))
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
@@ -634,9 +703,7 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
|
||||
|
||||
ap.task_mgr = SimpleNamespace()
|
||||
ap.task_mgr.create_user_task = Mock(
|
||||
return_value=SimpleNamespace(id=456)
|
||||
)
|
||||
ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=456))
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
@@ -645,4 +712,4 @@ class TestMCPServiceTestMCPServer:
|
||||
|
||||
# Verify - load_mcp_server called
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
assert task_id == 456
|
||||
|
||||
@@ -23,6 +23,7 @@ from langbot.pkg.api.http.service.model import (
|
||||
RerankModelsService,
|
||||
_parse_provider_api_keys,
|
||||
_runtime_model_data,
|
||||
_validate_provider_supports,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, RerankModel, ModelProvider
|
||||
|
||||
@@ -35,6 +36,7 @@ def _create_mock_llm_model(
|
||||
name: str = 'Test LLM',
|
||||
provider_uuid: str = 'provider-uuid',
|
||||
abilities: list = None,
|
||||
context_length: int | None = None,
|
||||
extra_args: dict = None,
|
||||
) -> Mock:
|
||||
"""Helper to create mock LLMModel entity."""
|
||||
@@ -43,6 +45,7 @@ def _create_mock_llm_model(
|
||||
model.name = name
|
||||
model.provider_uuid = provider_uuid
|
||||
model.abilities = abilities or []
|
||||
model.context_length = context_length
|
||||
model.extra_args = extra_args or {}
|
||||
return model
|
||||
|
||||
@@ -142,10 +145,12 @@ class TestRuntimeModelData:
|
||||
'name': 'Model',
|
||||
'provider_uuid': 'provider',
|
||||
'abilities': ['vision'],
|
||||
'context_length': 128000,
|
||||
'extra_args': {'temp': 0.7},
|
||||
}
|
||||
result = _runtime_model_data('uuid', update_payload)
|
||||
assert result['abilities'] == ['vision']
|
||||
assert result['context_length'] == 128000
|
||||
assert result['extra_args'] == {'temp': 0.7}
|
||||
|
||||
|
||||
@@ -162,6 +167,7 @@ class TestLLMModelsServiceGetLLMModels:
|
||||
mock_provider_result = _create_mock_result([])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
return mock_result if call_count == 0 else mock_provider_result
|
||||
|
||||
@@ -188,13 +194,14 @@ class TestLLMModelsServiceGetLLMModels:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
model = _create_mock_llm_model()
|
||||
model = _create_mock_llm_model(context_length=128000)
|
||||
provider = _create_mock_provider()
|
||||
|
||||
mock_model_result = _create_mock_result([model])
|
||||
mock_provider_result = _create_mock_result([provider])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -206,6 +213,7 @@ class TestLLMModelsServiceGetLLMModels:
|
||||
'uuid': entity.uuid,
|
||||
'name': entity.name,
|
||||
'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None,
|
||||
'context_length': getattr(entity, 'context_length', None),
|
||||
'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None,
|
||||
}
|
||||
)
|
||||
@@ -218,6 +226,7 @@ class TestLLMModelsServiceGetLLMModels:
|
||||
# Verify
|
||||
assert len(result) == 1
|
||||
assert result[0]['name'] == 'Test LLM'
|
||||
assert result[0]['context_length'] == 128000
|
||||
|
||||
async def test_get_llm_models_hide_secret_keys(self):
|
||||
"""Hides secret API keys when include_secret=False."""
|
||||
@@ -232,6 +241,7 @@ class TestLLMModelsServiceGetLLMModels:
|
||||
mock_provider_result = _create_mock_result([provider])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -265,13 +275,14 @@ class TestLLMModelsServiceGetLLMModel:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
model = _create_mock_llm_model(model_uuid='found-uuid')
|
||||
model = _create_mock_llm_model(model_uuid='found-uuid', context_length=128000)
|
||||
provider = _create_mock_provider()
|
||||
|
||||
mock_model_result = _create_mock_result([], first_item=model)
|
||||
mock_provider_result = _create_mock_result([], first_item=provider)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -279,11 +290,12 @@ class TestLLMModelsServiceGetLLMModel:
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'uuid': 'found-uuid',
|
||||
'name': 'Test LLM',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'provider': {'uuid': 'provider-uuid', 'api_keys': ['key']},
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'uuid': entity.uuid,
|
||||
'name': entity.name,
|
||||
'provider_uuid': getattr(entity, 'provider_uuid', None),
|
||||
'context_length': getattr(entity, 'context_length', None),
|
||||
'api_keys': getattr(entity, 'api_keys', None),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -295,6 +307,7 @@ class TestLLMModelsServiceGetLLMModel:
|
||||
# Verify
|
||||
assert result is not None
|
||||
assert result['uuid'] == 'found-uuid'
|
||||
assert result['context_length'] == 128000
|
||||
|
||||
async def test_get_llm_model_not_found(self):
|
||||
"""Returns None when model not found."""
|
||||
@@ -328,9 +341,7 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
|
||||
|
||||
mock_result = _create_mock_result([model1, model2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'model-1', 'name': 'Model 1'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'model-1', 'name': 'Model 1'})
|
||||
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
@@ -362,12 +373,14 @@ class TestLLMModelsServiceCreateLLMModel:
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
# Execute
|
||||
model_uuid = await service.create_llm_model({
|
||||
'name': 'New LLM',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
})
|
||||
model_uuid = await service.create_llm_model(
|
||||
{
|
||||
'name': 'New LLM',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert model_uuid is not None
|
||||
@@ -391,17 +404,53 @@ class TestLLMModelsServiceCreateLLMModel:
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
# Execute
|
||||
model_uuid = await service.create_llm_model({
|
||||
'uuid': 'preserved-uuid',
|
||||
'name': 'Preserved UUID Model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}, preserve_uuid=True)
|
||||
model_uuid = await service.create_llm_model(
|
||||
{
|
||||
'uuid': 'preserved-uuid',
|
||||
'name': 'Preserved UUID Model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert model_uuid == 'preserved-uuid'
|
||||
|
||||
async def test_create_llm_model_persists_context_length_as_column(self):
|
||||
"""Creates LLM model with context_length outside extra_args."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
|
||||
ap.model_mgr.llm_models = []
|
||||
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
|
||||
ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock())
|
||||
|
||||
mock_result = _create_mock_result([])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
await service.create_llm_model(
|
||||
{
|
||||
'uuid': 'model-with-context',
|
||||
'name': 'Context Model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': ['func_call'],
|
||||
'context_length': 128000,
|
||||
'extra_args': {'temperature': 0.2},
|
||||
},
|
||||
preserve_uuid=True,
|
||||
auto_set_to_default_pipeline=False,
|
||||
)
|
||||
|
||||
runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
|
||||
assert runtime_entity.context_length == 128000
|
||||
assert runtime_entity.extra_args == {'temperature': 0.2}
|
||||
assert 'context_length' not in runtime_entity.extra_args
|
||||
|
||||
async def test_create_llm_model_provider_not_found_raises_error(self):
|
||||
"""Raises Exception when provider not found in runtime."""
|
||||
# Setup
|
||||
@@ -417,12 +466,14 @@ class TestLLMModelsServiceCreateLLMModel:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(Exception, match='provider not found'):
|
||||
await service.create_llm_model({
|
||||
'name': 'No Provider Model',
|
||||
'provider_uuid': 'nonexistent-provider',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
})
|
||||
await service.create_llm_model(
|
||||
{
|
||||
'name': 'No Provider Model',
|
||||
'provider_uuid': 'nonexistent-provider',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
async def test_create_llm_model_with_provider_data(self):
|
||||
"""Creates provider when provider data provided."""
|
||||
@@ -448,16 +499,18 @@ class TestLLMModelsServiceCreateLLMModel:
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
# Execute - with provider data (no UUID)
|
||||
result_uuid = await service.create_llm_model({
|
||||
'name': 'Model with New Provider',
|
||||
'provider': {
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
},
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
})
|
||||
result_uuid = await service.create_llm_model(
|
||||
{
|
||||
'name': 'Model with New Provider',
|
||||
'provider': {
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
},
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
# Verify - provider_service was called and UUID generated
|
||||
ap.provider_service.find_or_create_provider.assert_called_once()
|
||||
@@ -483,11 +536,14 @@ class TestLLMModelsServiceUpdateLLMModel:
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
# Execute
|
||||
await service.update_llm_model('existing-uuid', {
|
||||
'uuid': 'should-be-removed',
|
||||
'name': 'Updated Name',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
})
|
||||
await service.update_llm_model(
|
||||
'existing-uuid',
|
||||
{
|
||||
'uuid': 'should-be-removed',
|
||||
'name': 'Updated Name',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
},
|
||||
)
|
||||
|
||||
# Verify - remove and load called
|
||||
ap.model_mgr.remove_llm_model.assert_called_once_with('existing-uuid')
|
||||
@@ -507,10 +563,42 @@ class TestLLMModelsServiceUpdateLLMModel:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(Exception, match='provider not found'):
|
||||
await service.update_llm_model('model-uuid', {
|
||||
'name': 'Update',
|
||||
'provider_uuid': 'nonexistent-provider',
|
||||
})
|
||||
await service.update_llm_model(
|
||||
'model-uuid',
|
||||
{
|
||||
'name': 'Update',
|
||||
'provider_uuid': 'nonexistent-provider',
|
||||
},
|
||||
)
|
||||
|
||||
async def test_update_llm_model_reloads_context_length_as_column(self):
|
||||
"""Updates runtime model with context_length outside extra_args."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
|
||||
ap.model_mgr.llm_models = []
|
||||
ap.model_mgr.remove_llm_model = AsyncMock()
|
||||
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
|
||||
|
||||
service = LLMModelsService(ap)
|
||||
|
||||
await service.update_llm_model(
|
||||
'existing-uuid',
|
||||
{
|
||||
'name': 'Updated Name',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': ['vision'],
|
||||
'context_length': 64000,
|
||||
'extra_args': {'temperature': 0.4},
|
||||
},
|
||||
)
|
||||
|
||||
runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
|
||||
assert runtime_entity.uuid == 'existing-uuid'
|
||||
assert runtime_entity.context_length == 64000
|
||||
assert runtime_entity.extra_args == {'temperature': 0.4}
|
||||
assert 'context_length' not in runtime_entity.extra_args
|
||||
|
||||
|
||||
class TestLLMModelsServiceDeleteLLMModel:
|
||||
@@ -547,9 +635,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
|
||||
|
||||
mock_result = _create_mock_result([])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'embedding-uuid', 'name': 'Test'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'embedding-uuid', 'name': 'Test'})
|
||||
|
||||
service = EmbeddingModelsService(ap)
|
||||
|
||||
@@ -572,6 +658,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
|
||||
mock_provider_result = _create_mock_result([provider])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -612,6 +699,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
|
||||
mock_provider_result = _create_mock_result([], first_item=provider)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -671,11 +759,13 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
|
||||
service = EmbeddingModelsService(ap)
|
||||
|
||||
# Execute
|
||||
model_uuid = await service.create_embedding_model({
|
||||
'name': 'New Embedding',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
})
|
||||
model_uuid = await service.create_embedding_model(
|
||||
{
|
||||
'name': 'New Embedding',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert model_uuid is not None
|
||||
@@ -696,11 +786,13 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(Exception, match='provider not found'):
|
||||
await service.create_embedding_model({
|
||||
'name': 'No Provider Embedding',
|
||||
'provider_uuid': 'nonexistent',
|
||||
'extra_args': {},
|
||||
})
|
||||
await service.create_embedding_model(
|
||||
{
|
||||
'name': 'No Provider Embedding',
|
||||
'provider_uuid': 'nonexistent',
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestEmbeddingModelsServiceDeleteEmbeddingModel:
|
||||
@@ -758,6 +850,7 @@ class TestRerankModelsServiceGetRerankModels:
|
||||
mock_provider_result = _create_mock_result([provider])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -798,6 +891,7 @@ class TestRerankModelsServiceGetRerankModel:
|
||||
mock_provider_result = _create_mock_result([], first_item=provider)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -857,11 +951,13 @@ class TestRerankModelsServiceCreateRerankModel:
|
||||
service = RerankModelsService(ap)
|
||||
|
||||
# Execute
|
||||
model_uuid = await service.create_rerank_model({
|
||||
'name': 'New Rerank',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
})
|
||||
model_uuid = await service.create_rerank_model(
|
||||
{
|
||||
'name': 'New Rerank',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert model_uuid is not None
|
||||
@@ -881,11 +977,13 @@ class TestRerankModelsServiceCreateRerankModel:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(Exception, match='provider not found'):
|
||||
await service.create_rerank_model({
|
||||
'name': 'No Provider Rerank',
|
||||
'provider_uuid': 'nonexistent',
|
||||
'extra_args': {},
|
||||
})
|
||||
await service.create_rerank_model(
|
||||
{
|
||||
'name': 'No Provider Rerank',
|
||||
'provider_uuid': 'nonexistent',
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestRerankModelsServiceDeleteRerankModel:
|
||||
@@ -924,9 +1022,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
|
||||
|
||||
mock_result = _create_mock_result([model1, model2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'emb-1', 'name': 'Embedding 1'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'emb-1', 'name': 'Embedding 1'})
|
||||
|
||||
service = EmbeddingModelsService(ap)
|
||||
|
||||
@@ -951,9 +1047,7 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
|
||||
|
||||
mock_result = _create_mock_result([model1, model2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'})
|
||||
|
||||
service = RerankModelsService(ap)
|
||||
|
||||
@@ -961,4 +1055,50 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
|
||||
result = await service.get_rerank_models_by_provider('provider-uuid')
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestValidateProviderSupports:
|
||||
"""Tests for _validate_provider_supports guard."""
|
||||
|
||||
@staticmethod
|
||||
def _make_ap(requester_name: str, support_type):
|
||||
"""Build a fake ap whose model_mgr resolves a manifest with support_type."""
|
||||
manifest = SimpleNamespace(spec={'support_type': support_type})
|
||||
runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester=requester_name))
|
||||
model_mgr = SimpleNamespace(
|
||||
provider_dict={'p1': runtime_provider},
|
||||
get_available_requester_manifest_by_name=lambda name: manifest if name == requester_name else None,
|
||||
)
|
||||
return SimpleNamespace(model_mgr=model_mgr)
|
||||
|
||||
async def test_allows_supported_type(self):
|
||||
ap = self._make_ap('cohere-rerank', ['rerank'])
|
||||
# Should not raise
|
||||
await _validate_provider_supports(ap, 'p1', 'rerank')
|
||||
|
||||
async def test_rejects_unsupported_type(self):
|
||||
ap = self._make_ap('cohere-rerank', ['rerank'])
|
||||
with pytest.raises(ValueError, match='does not support llm'):
|
||||
await _validate_provider_supports(ap, 'p1', 'llm')
|
||||
|
||||
async def test_allows_when_support_type_missing(self):
|
||||
# Manifest without support_type must not block (backward compatible)
|
||||
manifest = SimpleNamespace(spec={})
|
||||
runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester='legacy'))
|
||||
model_mgr = SimpleNamespace(
|
||||
provider_dict={'p1': runtime_provider},
|
||||
get_available_requester_manifest_by_name=lambda name: manifest,
|
||||
)
|
||||
ap = SimpleNamespace(model_mgr=model_mgr)
|
||||
await _validate_provider_supports(ap, 'p1', 'rerank')
|
||||
|
||||
async def test_allows_when_provider_unknown(self):
|
||||
ap = self._make_ap('cohere-rerank', ['rerank'])
|
||||
# Unknown provider uuid -> no entry -> no block
|
||||
await _validate_provider_supports(ap, 'missing', 'llm')
|
||||
|
||||
async def test_degrades_when_model_mgr_incomplete(self):
|
||||
# A bare ap without a usable model_mgr must not raise (defensive)
|
||||
ap = SimpleNamespace(model_mgr=SimpleNamespace())
|
||||
await _validate_provider_supports(ap, 'p1', 'llm')
|
||||
|
||||
@@ -215,13 +215,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {
|
||||
'system': {
|
||||
'limitation': {
|
||||
'max_pipelines': 2
|
||||
}
|
||||
}
|
||||
}
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
|
||||
ap.pipeline_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
ap.ver_mgr = SimpleNamespace()
|
||||
@@ -229,9 +223,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
mock_result = _create_mock_result([_create_mock_pipeline(), _create_mock_pipeline()])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'})
|
||||
|
||||
service = PipelineService(ap)
|
||||
|
||||
@@ -258,14 +250,14 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
# Mock persistence for insert
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})
|
||||
|
||||
# Mock the file read for default config - patch at the utils module level
|
||||
default_config = {'trigger': {}, 'safety': {}, 'ai': {}, 'output': {}}
|
||||
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
||||
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
bot_uuid = await service.create_pipeline({'name': 'New Pipeline'})
|
||||
|
||||
# Verify
|
||||
@@ -286,7 +278,9 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
service = PipelineService(ap)
|
||||
service.get_pipelines = AsyncMock(return_value=[])
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True})
|
||||
service.get_pipeline = AsyncMock(
|
||||
return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
@@ -296,7 +290,9 @@ class TestPipelineServiceCreatePipeline:
|
||||
# Mock the file read
|
||||
default_config = {}
|
||||
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
||||
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
await service.create_pipeline({'name': 'Default Pipeline'}, default=True)
|
||||
|
||||
# Verify - execute was called
|
||||
@@ -316,10 +312,12 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
service = PipelineService(ap)
|
||||
service.get_pipelines = AsyncMock(return_value=[])
|
||||
service.get_pipeline = AsyncMock(return_value={
|
||||
'uuid': 'new-uuid',
|
||||
'extensions_preferences': {},
|
||||
})
|
||||
service.get_pipeline = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'new-uuid',
|
||||
'extensions_preferences': {},
|
||||
}
|
||||
)
|
||||
|
||||
insert_params = []
|
||||
|
||||
@@ -339,7 +337,9 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
default_config = {}
|
||||
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
||||
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
await service.create_pipeline({'name': 'New Pipeline'})
|
||||
|
||||
assert len(insert_params) == 1
|
||||
@@ -348,11 +348,14 @@ class TestPipelineServiceCreatePipeline:
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
|
||||
class _MockResultWithBots:
|
||||
"""Helper class to mock SQLAlchemy result with iterable .all() method."""
|
||||
|
||||
def __init__(self, bots_list):
|
||||
self._bots_list = bots_list
|
||||
|
||||
@@ -428,6 +431,7 @@ class TestPipelineServiceUpdatePipeline:
|
||||
# 1. UPDATE (line 125) - returns Mock (no result needed)
|
||||
# 2. SELECT bots (line 136) - returns bot_result with .all()
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -528,13 +532,7 @@ class TestPipelineServiceCopyPipeline:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {
|
||||
'system': {
|
||||
'limitation': {
|
||||
'max_pipelines': 2
|
||||
}
|
||||
}
|
||||
}
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
|
||||
ap.pipeline_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
ap.ver_mgr = SimpleNamespace()
|
||||
@@ -542,10 +540,12 @@ class TestPipelineServiceCopyPipeline:
|
||||
|
||||
service = PipelineService(ap)
|
||||
# Mock get_pipelines to return 2 pipelines
|
||||
service.get_pipelines = AsyncMock(return_value=[
|
||||
{'uuid': 'uuid-1', 'name': 'Pipeline 1'},
|
||||
{'uuid': 'uuid-2', 'name': 'Pipeline 2'},
|
||||
])
|
||||
service.get_pipelines = AsyncMock(
|
||||
return_value=[
|
||||
{'uuid': 'uuid-1', 'name': 'Pipeline 1'},
|
||||
{'uuid': 'uuid-2', 'name': 'Pipeline 2'},
|
||||
]
|
||||
)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
||||
@@ -642,9 +642,7 @@ class TestPipelineServiceCopyPipeline:
|
||||
service = PipelineService(ap)
|
||||
service.get_pipelines = AsyncMock(return_value=[])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'copy-uuid', 'is_default': False}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'copy-uuid', 'is_default': False})
|
||||
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})
|
||||
|
||||
@@ -681,11 +679,10 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
|
||||
original_pipeline = _create_mock_pipeline(
|
||||
extensions_preferences={'enable_all_plugins': True, 'plugins': []}
|
||||
)
|
||||
original_pipeline = _create_mock_pipeline(extensions_preferences={'enable_all_plugins': True, 'plugins': []})
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -700,7 +697,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': False,
|
||||
'plugins': [{'plugin_uuid': 'plugin-1'}],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -711,7 +708,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': False,
|
||||
'plugins': [{'plugin_uuid': 'plugin-1'}],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -738,6 +735,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
original_pipeline = _create_mock_pipeline()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -752,7 +750,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
'extensions_preferences': {
|
||||
'enable_all_mcp_servers': False,
|
||||
'mcp_servers': ['mcp-server-1'],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -794,6 +792,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -817,6 +816,47 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
# Verify - persistence was called
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
|
||||
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
|
||||
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
|
||||
original_pipeline = _create_mock_pipeline(
|
||||
extensions_preferences={
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return _create_mock_result(first_item=original_pipeline)
|
||||
return Mock()
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
service = PipelineService(ap)
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
|
||||
|
||||
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
|
||||
assert original_pipeline.extensions_preferences['mcp_resources'] == [
|
||||
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
|
||||
]
|
||||
|
||||
|
||||
class TestDefaultStageOrder:
|
||||
"""Tests for default_stage_order constant."""
|
||||
|
||||
@@ -245,12 +245,14 @@ class TestModelProviderServiceCreateProvider:
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute
|
||||
provider_uuid = await service.create_provider({
|
||||
'name': 'New Provider',
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
})
|
||||
provider_uuid = await service.create_provider(
|
||||
{
|
||||
'name': 'New Provider',
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
}
|
||||
)
|
||||
|
||||
# Verify - UUID is generated
|
||||
assert provider_uuid is not None
|
||||
@@ -274,12 +276,14 @@ class TestModelProviderServiceCreateProvider:
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute
|
||||
result_uuid = await service.create_provider({
|
||||
'name': 'Runtime Provider',
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
})
|
||||
result_uuid = await service.create_provider(
|
||||
{
|
||||
'name': 'Runtime Provider',
|
||||
'requester': 'openai',
|
||||
'base_url': 'https://api.openai.com',
|
||||
'api_keys': ['key'],
|
||||
}
|
||||
)
|
||||
|
||||
# Verify - provider added to runtime dict and UUID generated
|
||||
ap.model_mgr.load_provider.assert_called_once()
|
||||
@@ -302,10 +306,13 @@ class TestModelProviderServiceUpdateProvider:
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute
|
||||
await service.update_provider('existing-uuid', {
|
||||
'uuid': 'should-be-removed', # Will be removed
|
||||
'name': 'Updated Name',
|
||||
})
|
||||
await service.update_provider(
|
||||
'existing-uuid',
|
||||
{
|
||||
'uuid': 'should-be-removed', # Will be removed
|
||||
'name': 'Updated Name',
|
||||
},
|
||||
)
|
||||
|
||||
# Verify - reload called
|
||||
ap.model_mgr.reload_provider.assert_called_once_with('existing-uuid')
|
||||
@@ -364,6 +371,7 @@ class TestModelProviderServiceDeleteProvider:
|
||||
rerank_result.first = Mock(return_value=None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -396,6 +404,7 @@ class TestModelProviderServiceDeleteProvider:
|
||||
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -454,6 +463,7 @@ class TestModelProviderServiceGetProviderModelCounts:
|
||||
rerank_result.scalar = Mock(return_value=1)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -637,9 +647,7 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys:
|
||||
await service.update_space_model_provider_api_keys('space-api-key')
|
||||
|
||||
# Verify - update and reload called for Space provider UUID
|
||||
ap.model_mgr.reload_provider.assert_called_once_with(
|
||||
'00000000-0000-0000-0000-000000000000'
|
||||
)
|
||||
ap.model_mgr.reload_provider.assert_called_once_with('00000000-0000-0000-0000-000000000000')
|
||||
|
||||
|
||||
class TestModelProviderServiceScanProviderModels:
|
||||
@@ -795,9 +803,7 @@ class TestModelProviderServiceScanProviderModels:
|
||||
runtime_provider.token_mgr = Mock()
|
||||
runtime_provider.token_mgr.get_token = Mock(return_value='token')
|
||||
runtime_provider.token_mgr.tokens = ['token']
|
||||
runtime_provider.requester.scan_models = AsyncMock(
|
||||
side_effect=NotImplementedError('scan not supported')
|
||||
)
|
||||
runtime_provider.requester.scan_models = AsyncMock(side_effect=NotImplementedError('scan not supported'))
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
@@ -848,9 +854,7 @@ class TestModelProviderServiceScanProviderModels:
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
|
||||
# Mock existing LLM model
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(
|
||||
return_value=[{'name': 'Existing Model'}]
|
||||
)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[{'name': 'Existing Model'}])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
@@ -863,4 +867,4 @@ class TestModelProviderServiceScanProviderModels:
|
||||
assert existing_model['already_added'] is True
|
||||
|
||||
new_model = next(m for m in result['models'] if m['name'] == 'New Model')
|
||||
assert new_model['already_added'] is False
|
||||
assert new_model['already_added'] is False
|
||||
|
||||
@@ -393,14 +393,16 @@ class TestSpaceServiceRefreshToken:
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'access_token': 'new_access_token',
|
||||
'refresh_token': 'new_refresh_token',
|
||||
'expires_in': 3600,
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'access_token': 'new_access_token',
|
||||
'refresh_token': 'new_refresh_token',
|
||||
'expires_in': 3600,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -429,10 +431,12 @@ class TestSpaceServiceRefreshToken:
|
||||
# Mock HTTP response with error
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
'code': 1,
|
||||
'msg': 'Invalid refresh token',
|
||||
})
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
'code': 1,
|
||||
'msg': 'Invalid refresh token',
|
||||
}
|
||||
)
|
||||
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid refresh token"}')
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
@@ -489,14 +493,16 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'access_token': 'new_access_token',
|
||||
'refresh_token': 'new_refresh_token',
|
||||
'expires_in': 3600,
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'access_token': 'new_access_token',
|
||||
'refresh_token': 'new_refresh_token',
|
||||
'expires_in': 3600,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -555,13 +561,15 @@ class TestSpaceServiceGetUserInfoRaw:
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'email': 'test@example.com',
|
||||
'credits': 100,
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'email': 'test@example.com',
|
||||
'credits': 100,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -669,27 +677,29 @@ class TestSpaceServiceGetModels:
|
||||
# Mock HTTP response with proper model data matching SpaceModel schema
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'models': [
|
||||
{
|
||||
'uuid': 'uuid-1',
|
||||
'model_id': 'model-1',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'uuid-2',
|
||||
'model_id': 'model-2',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'models': [
|
||||
{
|
||||
'uuid': 'uuid-1',
|
||||
'model_id': 'model-1',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'uuid-2',
|
||||
'model_id': 'model-2',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -775,4 +785,4 @@ class TestSpaceServiceCreditsCache:
|
||||
# Verify - cache updated
|
||||
assert result == 500
|
||||
assert 'test@example.com' in service._credits_cache
|
||||
assert service._credits_cache['test@example.com'][0] == 500
|
||||
assert service._credits_cache['test@example.com'][0] == 500
|
||||
|
||||
@@ -495,6 +495,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
|
||||
# First call (line 138) returns None, second call (line 194) returns new_user
|
||||
call_count = 0
|
||||
|
||||
async def mock_get_by_space_uuid(uuid):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -565,6 +566,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
|
||||
# First call (line 138) returns None, second call (line 194) returns new_user
|
||||
call_count = 0
|
||||
|
||||
async def mock_get_by_space_uuid(uuid):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -605,4 +607,4 @@ class TestUserServiceCreateUserLock:
|
||||
|
||||
# Verify lock exists
|
||||
assert hasattr(service, '_create_user_lock')
|
||||
assert service._create_user_lock is not None
|
||||
assert service._create_user_lock is not None
|
||||
|
||||
@@ -132,6 +132,7 @@ class TestWebhookServiceCreateWebhook:
|
||||
|
||||
# execute_async returns different results
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -181,6 +182,7 @@ class TestWebhookServiceCreateWebhook:
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -217,6 +219,7 @@ class TestWebhookServiceCreateWebhook:
|
||||
created_webhook = _create_mock_webhook(webhook_id=1, enabled=False)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -225,9 +228,7 @@ class TestWebhookServiceCreateWebhook:
|
||||
return _create_mock_result(first_item=created_webhook)
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'id': 1, 'enabled': False}
|
||||
)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'id': 1, 'enabled': False})
|
||||
|
||||
service = WebhookService(ap)
|
||||
|
||||
@@ -503,4 +504,4 @@ class TestWebhookServiceGetEnabledWebhooks:
|
||||
result = await service.get_enabled_webhooks()
|
||||
|
||||
# Verify - should be empty (SQL would filter disabled)
|
||||
assert result == []
|
||||
assert result == []
|
||||
|
||||
@@ -12,7 +12,8 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
@pytest.mark.parametrize('api_key', [None, 123, b'lbk_bytes', '', 'plain_key', ' LBK_bad', 'sk-lbk_fake'])
|
||||
async def test_verify_api_key_rejects_non_lbk_keys_without_db_query(api_key):
|
||||
persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
|
||||
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
|
||||
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
|
||||
|
||||
result = await service.verify_api_key(api_key)
|
||||
|
||||
@@ -32,7 +33,8 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(db_row, expected)
|
||||
query_result = Mock()
|
||||
query_result.first.return_value = db_row
|
||||
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=query_result))
|
||||
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
|
||||
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
|
||||
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
|
||||
|
||||
result = await service.verify_api_key('lbk_valid_format')
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.box_visibility import should_hide_box_runtime_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('edition', 'box_enabled', 'expected'),
|
||||
[
|
||||
('cloud', False, True),
|
||||
('cloud', True, False),
|
||||
('cloud', None, False),
|
||||
('community', False, False),
|
||||
],
|
||||
)
|
||||
def test_should_hide_box_runtime_status(edition, box_enabled, expected):
|
||||
assert should_hide_box_runtime_status(edition, box_enabled) is expected
|
||||
@@ -0,0 +1,76 @@
|
||||
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(mcp_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(mcp_service=mcp_service, user_service=user_service)
|
||||
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
|
||||
group = MCPRouterGroup(ap, app)
|
||||
await group.initialize()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
async def test_mcp_server_route_accepts_encoded_slash_name():
|
||||
mcp_service = SimpleNamespace(
|
||||
get_mcp_server_by_name=AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'test-uuid',
|
||||
'name': 'pab1it0/prometheus',
|
||||
'enable': True,
|
||||
'mode': 'stdio',
|
||||
'extra_args': {},
|
||||
}
|
||||
)
|
||||
)
|
||||
client = await _create_test_client(mcp_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
|
||||
|
||||
|
||||
async def test_mcp_resource_route_accepts_encoded_slash_name():
|
||||
mcp_service = SimpleNamespace(
|
||||
get_mcp_server_by_name=AsyncMock(),
|
||||
get_mcp_server_resources=AsyncMock(return_value=[]),
|
||||
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
|
||||
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
|
||||
)
|
||||
client = await _create_test_client(mcp_service)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mcp_service.get_mcp_server_by_name.assert_not_awaited()
|
||||
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
|
||||
payload = await response.get_json()
|
||||
assert payload['data']['resource_capabilities'] == {'subscribe': False}
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||
from langbot.pkg.box.connector import BoxRuntimeConnector
|
||||
|
||||
|
||||
def make_app(logger: Mock, runtime_endpoint: str = ''):
|
||||
return SimpleNamespace(
|
||||
logger=logger,
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'box': {
|
||||
'backend': 'local',
|
||||
'runtime': {'endpoint': runtime_endpoint},
|
||||
'local': {
|
||||
'profile': 'default',
|
||||
'allowed_mount_roots': [],
|
||||
'default_workspace': '',
|
||||
},
|
||||
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Without runtime.endpoint, on a non-Docker Unix platform, use stdio."""
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
|
||||
assert connector._uses_websocket() is False
|
||||
assert isinstance(connector.client, ActionRPCBoxClient)
|
||||
|
||||
|
||||
def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch):
|
||||
"""With an explicit runtime.endpoint, always use WebSocket."""
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
logger = Mock()
|
||||
connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410'))
|
||||
|
||||
assert connector._uses_websocket() is True
|
||||
assert isinstance(connector.client, ActionRPCBoxClient)
|
||||
|
||||
|
||||
def test_box_runtime_connector_ws_in_docker(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Inside Docker (no explicit URL), use WebSocket to reach a sibling container."""
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'docker')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
|
||||
assert connector._uses_websocket() is True
|
||||
assert connector.ws_relay_base_url == 'http://langbot_box:5410'
|
||||
|
||||
|
||||
def test_box_runtime_connector_ws_with_standalone_flag(monkeypatch: pytest.MonkeyPatch):
|
||||
"""With --standalone-box flag, use WebSocket even on a local Unix platform."""
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', True)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
|
||||
assert connector._uses_websocket() is True
|
||||
|
||||
|
||||
def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
|
||||
assert connector.ws_relay_base_url == 'http://127.0.0.1:5410'
|
||||
|
||||
|
||||
def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
|
||||
assert connector.ws_relay_base_url == 'http://box-runtime:5410'
|
||||
|
||||
|
||||
def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
logger = Mock()
|
||||
connector = BoxRuntimeConnector(make_app(logger))
|
||||
subprocess = Mock()
|
||||
subprocess.returncode = None
|
||||
handler_task = Mock()
|
||||
ctrl_task = Mock()
|
||||
connector._subprocess = subprocess
|
||||
connector._handler_task = handler_task
|
||||
connector._ctrl_task = ctrl_task
|
||||
|
||||
connector.dispose()
|
||||
|
||||
subprocess.terminate.assert_called_once()
|
||||
handler_task.cancel.assert_called_once()
|
||||
ctrl_task.cancel.assert_called_once()
|
||||
assert connector._handler_task is None
|
||||
assert connector._ctrl_task is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.workspace import (
|
||||
BoxWorkspaceSession,
|
||||
classify_python_workspace,
|
||||
infer_workspace_host_path,
|
||||
rewrite_mounted_path,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_mounted_path_translates_host_prefix():
|
||||
result = rewrite_mounted_path('/tmp/demo/project/app.py', '/tmp/demo/project')
|
||||
assert result == '/workspace/app.py'
|
||||
|
||||
|
||||
def test_infer_workspace_host_path_unwraps_virtualenv_bin_dir():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_root = os.path.join(tmpdir, 'project')
|
||||
os.makedirs(os.path.join(project_root, '.venv', 'bin'))
|
||||
python_bin = os.path.join(project_root, '.venv', 'bin', 'python')
|
||||
script = os.path.join(project_root, 'server.py')
|
||||
|
||||
with open(python_bin, 'w', encoding='utf-8') as handle:
|
||||
handle.write('')
|
||||
with open(script, 'w', encoding='utf-8') as handle:
|
||||
handle.write('print("ok")\n')
|
||||
|
||||
result = infer_workspace_host_path(python_bin, [script])
|
||||
|
||||
assert result == os.path.realpath(project_root)
|
||||
|
||||
|
||||
def test_classify_python_workspace_detects_package_and_requirements():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
assert classify_python_workspace(tmpdir) is None
|
||||
|
||||
with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as handle:
|
||||
handle.write('requests\n')
|
||||
assert classify_python_workspace(tmpdir) == 'requirements'
|
||||
|
||||
with open(os.path.join(tmpdir, 'pyproject.toml'), 'w', encoding='utf-8') as handle:
|
||||
handle.write('[project]\nname = "demo"\n')
|
||||
assert classify_python_workspace(tmpdir) == 'package'
|
||||
|
||||
|
||||
def test_wrap_python_command_with_env_contains_bootstrap_and_command():
|
||||
command = wrap_python_command_with_env('python script.py')
|
||||
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'kill -0 "$_LB_LOCK_OWNER"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python script.py')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_session_execute_for_query_uses_session_payload():
|
||||
box_service = SimpleNamespace(execute_spec_payload=AsyncMock(return_value={'ok': True}))
|
||||
workspace = BoxWorkspaceSession(
|
||||
box_service,
|
||||
'skill-person_123-demo',
|
||||
host_path='/tmp/project',
|
||||
host_path_mode='rw',
|
||||
env={'FOO': 'bar'},
|
||||
)
|
||||
|
||||
query = SimpleNamespace(query_id='q1')
|
||||
result = await workspace.execute_for_query(query, 'python run.py', workdir='/workspace', timeout_sec=30)
|
||||
|
||||
assert result == {'ok': True}
|
||||
payload = box_service.execute_spec_payload.await_args.args[0]
|
||||
assert payload == {
|
||||
'session_id': 'skill-person_123-demo',
|
||||
'workdir': '/workspace',
|
||||
'env': {'FOO': 'bar'},
|
||||
'persistent': False,
|
||||
'host_path': '/tmp/project',
|
||||
'host_path_mode': 'rw',
|
||||
'cmd': 'python run.py',
|
||||
'timeout_sec': 30,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_session_start_managed_process_rewrites_command_and_args():
|
||||
box_service = SimpleNamespace(start_managed_process=AsyncMock(return_value={'status': 'running'}))
|
||||
workspace = BoxWorkspaceSession(
|
||||
box_service,
|
||||
'mcp-u1',
|
||||
host_path='/tmp/project',
|
||||
host_path_mode='ro',
|
||||
)
|
||||
|
||||
result = await workspace.start_managed_process(
|
||||
'/tmp/project/.venv/bin/python',
|
||||
['/tmp/project/server.py', '--config', '/tmp/project/config.json'],
|
||||
env={'TOKEN': '1'},
|
||||
)
|
||||
|
||||
assert result == {'status': 'running'}
|
||||
session_id = box_service.start_managed_process.await_args.args[0]
|
||||
payload = box_service.start_managed_process.await_args.args[1]
|
||||
assert session_id == 'mcp-u1'
|
||||
assert payload == {
|
||||
'command': 'python',
|
||||
'args': ['/workspace/server.py', '--config', '/workspace/config.json'],
|
||||
'env': {'TOKEN': '1'},
|
||||
'cwd': '/workspace',
|
||||
'process_id': 'default',
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_session_build_session_payload_keeps_generic_workspace_shape():
|
||||
workspace = BoxWorkspaceSession(
|
||||
Mock(),
|
||||
'workspace-1',
|
||||
host_path='/tmp/project',
|
||||
host_path_mode='rw',
|
||||
env={'FOO': 'bar'},
|
||||
network='on',
|
||||
read_only_rootfs=False,
|
||||
image='python:3.11',
|
||||
cpus=1.0,
|
||||
memory_mb=512,
|
||||
pids_limit=128,
|
||||
)
|
||||
|
||||
assert workspace.build_session_payload() == {
|
||||
'session_id': 'workspace-1',
|
||||
'workdir': '/workspace',
|
||||
'env': {'FOO': 'bar'},
|
||||
'persistent': False,
|
||||
'network': 'on',
|
||||
'read_only_rootfs': False,
|
||||
'host_path': '/tmp/project',
|
||||
'host_path_mode': 'rw',
|
||||
'image': 'python:3.11',
|
||||
'cpus': 1.0,
|
||||
'memory_mb': 512,
|
||||
'pids_limit': 128,
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
# Unit tests for command module
|
||||
# Unit tests for command module
|
||||
|
||||
@@ -529,4 +529,4 @@ class TestEmptyAndEdgeInputs:
|
||||
|
||||
# Should yield CommandNotFoundError (no such command registered)
|
||||
assert len(results) == 1
|
||||
assert results[0].error is not None
|
||||
assert results[0].error is not None
|
||||
|
||||
@@ -197,6 +197,7 @@ class TestCommandOperatorBase:
|
||||
op = TestOperator(None)
|
||||
# Should not raise
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(op.initialize())
|
||||
|
||||
def test_execute_is_abstract(self):
|
||||
@@ -299,4 +300,4 @@ class TestMultipleOperators:
|
||||
yield None
|
||||
|
||||
assert AdminOperator.lowest_privilege == 2
|
||||
assert SubOperator.lowest_privilege == 1
|
||||
assert SubOperator.lowest_privilege == 1
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestYAMLConfigFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_yaml_loads(self, tmp_path):
|
||||
"""Valid YAML config should load correctly."""
|
||||
config_file = tmp_path / "test_config.yaml"
|
||||
config_file = tmp_path / 'test_config.yaml'
|
||||
|
||||
# Write valid YAML
|
||||
config_file.write_text("""
|
||||
@@ -51,7 +51,7 @@ settings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_yaml_raises_error(self, tmp_path):
|
||||
"""Invalid YAML should raise clear error."""
|
||||
config_file = tmp_path / "invalid.yaml"
|
||||
config_file = tmp_path / 'invalid.yaml'
|
||||
|
||||
# Write invalid YAML (unclosed bracket)
|
||||
config_file.write_text("""
|
||||
@@ -67,13 +67,13 @@ settings:
|
||||
template_data={'name': 'default'},
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Syntax error"):
|
||||
with pytest.raises(Exception, match='Syntax error'):
|
||||
await yaml_file.load(completion=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_config_creates_from_template(self, tmp_path):
|
||||
"""Missing config file should be created from template."""
|
||||
config_file = tmp_path / "new_config.yaml"
|
||||
config_file = tmp_path / 'new_config.yaml'
|
||||
|
||||
# File doesn't exist yet
|
||||
assert not config_file.exists()
|
||||
@@ -92,7 +92,7 @@ settings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_completion(self, tmp_path):
|
||||
"""Config should be completed with template defaults."""
|
||||
config_file = tmp_path / "partial.yaml"
|
||||
config_file = tmp_path / 'partial.yaml'
|
||||
|
||||
# Write partial config missing some template keys
|
||||
config_file.write_text("""
|
||||
@@ -115,7 +115,7 @@ name: custom_name
|
||||
@pytest.mark.asyncio
|
||||
async def test_yaml_save(self, tmp_path):
|
||||
"""YAML config can be saved."""
|
||||
config_file = tmp_path / "save_test.yaml"
|
||||
config_file = tmp_path / 'save_test.yaml'
|
||||
|
||||
yaml_file = YAMLConfigFile(
|
||||
str(config_file),
|
||||
@@ -131,7 +131,7 @@ name: custom_name
|
||||
|
||||
def test_yaml_save_sync(self, tmp_path):
|
||||
"""YAML config can be saved synchronously."""
|
||||
config_file = tmp_path / "sync_save.yaml"
|
||||
config_file = tmp_path / 'sync_save.yaml'
|
||||
|
||||
yaml_file = YAMLConfigFile(
|
||||
str(config_file),
|
||||
@@ -151,14 +151,18 @@ class TestJSONConfigFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_json_loads(self, tmp_path):
|
||||
"""Valid JSON config should load correctly."""
|
||||
config_file = tmp_path / "test_config.json"
|
||||
config_file = tmp_path / 'test_config.json'
|
||||
|
||||
# Write valid JSON
|
||||
config_file.write_text(json.dumps({
|
||||
'name': 'json_app',
|
||||
'version': '1.0',
|
||||
'settings': {'debug': True, 'port': 8080},
|
||||
}))
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'name': 'json_app',
|
||||
'version': '1.0',
|
||||
'settings': {'debug': True, 'port': 8080},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
json_file = JSONConfigFile(
|
||||
str(config_file),
|
||||
@@ -174,7 +178,7 @@ class TestJSONConfigFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_raises_error(self, tmp_path):
|
||||
"""Invalid JSON should raise clear error."""
|
||||
config_file = tmp_path / "invalid.json"
|
||||
config_file = tmp_path / 'invalid.json'
|
||||
|
||||
# Write invalid JSON (missing closing brace)
|
||||
config_file.write_text('{"name": "test", "unclosed": ')
|
||||
@@ -184,13 +188,13 @@ class TestJSONConfigFile:
|
||||
template_data={'name': 'default'},
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Syntax error"):
|
||||
with pytest.raises(Exception, match='Syntax error'):
|
||||
await json_file.load(completion=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_json_creates_from_template(self, tmp_path):
|
||||
"""Missing JSON file should be created from template."""
|
||||
config_file = tmp_path / "new_config.json"
|
||||
config_file = tmp_path / 'new_config.json'
|
||||
|
||||
json_file = JSONConfigFile(
|
||||
str(config_file),
|
||||
@@ -205,7 +209,7 @@ class TestJSONConfigFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_save(self, tmp_path):
|
||||
"""JSON config can be saved."""
|
||||
config_file = tmp_path / "save_test.json"
|
||||
config_file = tmp_path / 'save_test.json'
|
||||
|
||||
json_file = JSONConfigFile(
|
||||
str(config_file),
|
||||
@@ -226,7 +230,7 @@ class TestConfigManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_manager_load(self, tmp_path):
|
||||
"""ConfigManager loads config correctly."""
|
||||
config_file = tmp_path / "manager_test.yaml"
|
||||
config_file = tmp_path / 'manager_test.yaml'
|
||||
config_file.write_text('name: managed_app\nversion: "1.0"\n')
|
||||
|
||||
yaml_file = YAMLConfigFile(
|
||||
@@ -243,7 +247,7 @@ class TestConfigManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_manager_dump(self, tmp_path):
|
||||
"""ConfigManager can dump config."""
|
||||
config_file = tmp_path / "dump_test.yaml"
|
||||
config_file = tmp_path / 'dump_test.yaml'
|
||||
|
||||
yaml_file = YAMLConfigFile(
|
||||
str(config_file),
|
||||
@@ -260,7 +264,7 @@ class TestConfigManager:
|
||||
|
||||
def test_config_manager_dump_sync(self, tmp_path):
|
||||
"""ConfigManager can dump config synchronously."""
|
||||
config_file = tmp_path / "sync_dump.yaml"
|
||||
config_file = tmp_path / 'sync_dump.yaml'
|
||||
|
||||
yaml_file = YAMLConfigFile(
|
||||
str(config_file),
|
||||
@@ -280,7 +284,7 @@ class TestConfigExists:
|
||||
|
||||
def test_yaml_exists_true(self, tmp_path):
|
||||
"""exists() returns True for existing file."""
|
||||
config_file = tmp_path / "exists.yaml"
|
||||
config_file = tmp_path / 'exists.yaml'
|
||||
config_file.write_text('name: test')
|
||||
|
||||
yaml_file = YAMLConfigFile(str(config_file), template_data={})
|
||||
@@ -288,14 +292,14 @@ class TestConfigExists:
|
||||
|
||||
def test_yaml_exists_false(self, tmp_path):
|
||||
"""exists() returns False for missing file."""
|
||||
config_file = tmp_path / "missing.yaml"
|
||||
config_file = tmp_path / 'missing.yaml'
|
||||
|
||||
yaml_file = YAMLConfigFile(str(config_file), template_data={})
|
||||
assert yaml_file.exists() is False
|
||||
|
||||
def test_json_exists_true(self, tmp_path):
|
||||
"""exists() returns True for existing JSON file."""
|
||||
config_file = tmp_path / "exists.json"
|
||||
config_file = tmp_path / 'exists.json'
|
||||
config_file.write_text('{}')
|
||||
|
||||
json_file = JSONConfigFile(str(config_file), template_data={})
|
||||
@@ -303,7 +307,7 @@ class TestConfigExists:
|
||||
|
||||
def test_json_exists_false(self, tmp_path):
|
||||
"""exists() returns False for missing JSON file."""
|
||||
config_file = tmp_path / "missing.json"
|
||||
config_file = tmp_path / 'missing.json'
|
||||
|
||||
json_file = JSONConfigFile(str(config_file), template_data={})
|
||||
assert json_file.exists() is False
|
||||
assert json_file.exists() is False
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Core module unit tests."""
|
||||
"""Core module unit tests."""
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests cover:
|
||||
- _get_positive_int_config() validation
|
||||
- _get_positive_float_config() validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
@@ -188,4 +189,4 @@ class TestGetPositiveFloatConfig:
|
||||
result = app._get_positive_float_config('not-a-number', default=1.5, name='test.config')
|
||||
|
||||
assert result == 1.5
|
||||
mock_logger.warning.assert_called_once()
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
@@ -27,6 +27,7 @@ class TestCheckDeps:
|
||||
from langbot.pkg.core.bootutils.deps import check_deps
|
||||
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(check_deps())
|
||||
|
||||
assert result == []
|
||||
@@ -46,6 +47,7 @@ class TestCheckDeps:
|
||||
from langbot.pkg.core.bootutils.deps import check_deps
|
||||
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(check_deps())
|
||||
|
||||
assert 'requests' in result
|
||||
@@ -61,6 +63,7 @@ class TestCheckDeps:
|
||||
from langbot.pkg.core.bootutils.deps import check_deps, required_deps
|
||||
|
||||
import asyncio
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(check_deps())
|
||||
|
||||
# Should include all required_deps keys
|
||||
@@ -107,6 +110,7 @@ class TestPrecheckPluginDeps:
|
||||
with patch('os.path.exists', return_value=False):
|
||||
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())
|
||||
|
||||
mock_install.assert_not_called()
|
||||
@@ -129,6 +133,7 @@ class TestPrecheckPluginDeps:
|
||||
with patch('os.listdir', side_effect=mock_listdir):
|
||||
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())
|
||||
|
||||
mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the daily-grouped rotating log file handler.
|
||||
|
||||
Regression coverage for the bug where a long-running process names its log
|
||||
file after the *start* day and keeps appending to it across midnight, so no
|
||||
file ever appears for the current day. See
|
||||
``langbot.pkg.core.bootutils.log.DailyGroupedRotatingFileHandler``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import langbot.pkg.core.bootutils.log as logmod
|
||||
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
|
||||
|
||||
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
|
||||
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||
|
||||
|
||||
def _listing(directory):
|
||||
return sorted(os.listdir(directory))
|
||||
|
||||
|
||||
def _make_logger(handler, name):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
|
||||
class TestDailyGroupedRotatingFileHandler:
|
||||
def _patch_date(self, monkeypatch, box):
|
||||
"""Make the handler read its current date from ``box['date']``."""
|
||||
|
||||
def fake_strftime(fmt, t=None):
|
||||
if fmt == '%Y-%m-%d':
|
||||
return box['date']
|
||||
return '00:00:00'
|
||||
|
||||
monkeypatch.setattr(logmod.time, 'strftime', fake_strftime)
|
||||
|
||||
def test_initial_file_named_for_current_day(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_initial')
|
||||
logger.info('hello')
|
||||
handler.close()
|
||||
|
||||
assert _listing(tmp_path) == ['langbot-2026-06-08.log']
|
||||
|
||||
def test_same_day_size_rotation_creates_numbered_backups(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_size')
|
||||
for i in range(40):
|
||||
logger.info('padding line to exceed maxBytes %d', i)
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
assert 'langbot-2026-06-08.log' in files
|
||||
assert any(f.startswith('langbot-2026-06-08.log.') for f in files)
|
||||
|
||||
def test_rolls_to_new_file_when_day_changes(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_midnight')
|
||||
logger.info('day1 line')
|
||||
|
||||
# Simulate crossing midnight within the same running process.
|
||||
box['date'] = '2026-06-09'
|
||||
logger.info('day2 line after midnight')
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
assert 'langbot-2026-06-08.log' in files
|
||||
assert 'langbot-2026-06-09.log' in files
|
||||
|
||||
day2 = (tmp_path / 'langbot-2026-06-09.log').read_text(encoding='utf-8')
|
||||
assert 'day2 line after midnight' in day2
|
||||
assert 'day1 line' not in day2
|
||||
|
||||
def test_rollover_repeats_across_multiple_days(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_multiday')
|
||||
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
|
||||
box['date'] = day
|
||||
logger.info('line for %s', day)
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
|
||||
assert f'langbot-{day}.log' in files
|
||||
|
||||
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_pattern')
|
||||
for i in range(40):
|
||||
logger.info('padding line %d', i)
|
||||
box['date'] = '2026-06-09'
|
||||
logger.info('next day line')
|
||||
handler.close()
|
||||
|
||||
for name in _listing(tmp_path):
|
||||
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
- Dict type skipping
|
||||
- Missing key creation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -248,15 +249,8 @@ class TestApplyEnvOverridesToConfig:
|
||||
"""Test multiple env vars applied in order."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
cfg = {
|
||||
'system': {'name': 'default', 'enable': True},
|
||||
'concurrency': {'pipeline': 5}
|
||||
}
|
||||
env = {
|
||||
'SYSTEM__NAME': 'custom',
|
||||
'SYSTEM__ENABLE': 'false',
|
||||
'CONCURRENCY__PIPELINE': '10'
|
||||
}
|
||||
cfg = {'system': {'name': 'default', 'enable': True}, 'concurrency': {'pipeline': 5}}
|
||||
env = {'SYSTEM__NAME': 'custom', 'SYSTEM__ENABLE': 'false', 'CONCURRENCY__PIPELINE': '10'}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
@@ -287,4 +281,4 @@ class TestApplyEnvOverridesToConfig:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
|
||||
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Tests for core migration registration and abstract classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from tests.utils.import_isolation import isolated_sys_modules
|
||||
|
||||
|
||||
class TestMigrationClassDecorator:
|
||||
"""Tests for @migration_class decorator."""
|
||||
|
||||
def _make_migration_import_mocks(self):
|
||||
"""Create mocks for migration import."""
|
||||
return {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
}
|
||||
|
||||
def test_migration_class_registers_migration(self):
|
||||
"""@migration_class registers migration in preregistered_migrations."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class, preregistered_migrations
|
||||
|
||||
# Clear for clean test
|
||||
preregistered_migrations.clear()
|
||||
|
||||
@migration_class('test-migration', 1)
|
||||
class TestMigration:
|
||||
pass
|
||||
|
||||
assert len(preregistered_migrations) == 1
|
||||
assert preregistered_migrations[0] == TestMigration
|
||||
|
||||
def test_migration_class_sets_name_attribute(self):
|
||||
"""@migration_class sets name attribute on class."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class
|
||||
|
||||
@migration_class('test-migration', 1)
|
||||
class TestMigration:
|
||||
pass
|
||||
|
||||
assert TestMigration.name == 'test-migration'
|
||||
|
||||
def test_migration_class_sets_number_attribute(self):
|
||||
"""@migration_class sets number attribute on class."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class
|
||||
|
||||
@migration_class('test-migration', 42)
|
||||
class TestMigration:
|
||||
pass
|
||||
|
||||
assert TestMigration.number == 42
|
||||
|
||||
def test_migration_class_returns_original_class(self):
|
||||
"""@migration_class returns the original class."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class
|
||||
|
||||
@migration_class('test', 1)
|
||||
class TestMigration:
|
||||
custom_attr = 'value'
|
||||
|
||||
assert TestMigration.custom_attr == 'value'
|
||||
|
||||
def test_migration_class_multiple_migrations(self):
|
||||
"""Multiple migrations can be registered."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class, preregistered_migrations
|
||||
|
||||
preregistered_migrations.clear()
|
||||
|
||||
@migration_class('migration1', 1)
|
||||
class Migration1:
|
||||
pass
|
||||
|
||||
@migration_class('migration2', 2)
|
||||
class Migration2:
|
||||
pass
|
||||
|
||||
assert len(preregistered_migrations) == 2
|
||||
assert preregistered_migrations[0] == Migration1
|
||||
assert preregistered_migrations[1] == Migration2
|
||||
|
||||
|
||||
class TestMigrationAbstractClass:
|
||||
"""Tests for Migration abstract class."""
|
||||
|
||||
def _make_migration_import_mocks(self):
|
||||
return {'langbot.pkg.core.app': MagicMock()}
|
||||
|
||||
def test_migration_is_abstract(self):
|
||||
"""Migration is abstract and cannot be instantiated directly."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Migration(MagicMock())
|
||||
|
||||
def test_migration_requires_need_migrate_method(self):
|
||||
"""Subclass must implement need_migrate method."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
class IncompleteMigration(Migration):
|
||||
async def run(self):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteMigration(MagicMock())
|
||||
|
||||
def test_migration_requires_run_method(self):
|
||||
"""Subclass must implement run method."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
class IncompleteMigration(Migration):
|
||||
async def need_migrate(self) -> bool:
|
||||
return False
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteMigration(MagicMock())
|
||||
|
||||
def test_migration_subclass_works(self):
|
||||
"""Complete subclass can be instantiated."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
class CompleteMigration(Migration):
|
||||
async def need_migrate(self) -> bool:
|
||||
return True
|
||||
|
||||
async def run(self):
|
||||
pass
|
||||
|
||||
mock_ap = MagicMock()
|
||||
migration = CompleteMigration(mock_ap)
|
||||
assert migration.ap == mock_ap
|
||||
|
||||
def test_migration_stores_app_reference(self):
|
||||
"""Migration stores ap reference in __init__."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
class TestMigration(Migration):
|
||||
async def need_migrate(self) -> bool:
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
pass
|
||||
|
||||
mock_ap = MagicMock()
|
||||
migration = TestMigration(mock_ap)
|
||||
assert migration.ap is mock_ap
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_need_migrate_returns_bool(self):
|
||||
"""need_migrate must return bool."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import Migration
|
||||
|
||||
class TestMigration(Migration):
|
||||
async def need_migrate(self) -> bool:
|
||||
return True
|
||||
|
||||
async def run(self):
|
||||
pass
|
||||
|
||||
migration = TestMigration(MagicMock())
|
||||
result = await migration.need_migrate()
|
||||
assert isinstance(result, bool)
|
||||
assert result == True
|
||||
|
||||
|
||||
class TestPreregisteredMigrations:
|
||||
"""Tests for preregistered_migrations global registry."""
|
||||
|
||||
def _make_migration_import_mocks(self):
|
||||
return {'langbot.pkg.core.app': MagicMock()}
|
||||
|
||||
def test_preregistered_migrations_is_list(self):
|
||||
"""preregistered_migrations is a list."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import preregistered_migrations
|
||||
|
||||
assert isinstance(preregistered_migrations, list)
|
||||
|
||||
def test_preregistered_migrations_order(self):
|
||||
"""Migrations are registered in order of decoration."""
|
||||
mocks = self._make_migration_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.migration import migration_class, preregistered_migrations
|
||||
|
||||
preregistered_migrations.clear()
|
||||
|
||||
@migration_class('first', 1)
|
||||
class First:
|
||||
pass
|
||||
|
||||
@migration_class('second', 2)
|
||||
class Second:
|
||||
pass
|
||||
|
||||
@migration_class('third', 3)
|
||||
class Third:
|
||||
pass
|
||||
|
||||
# Order should match decoration order
|
||||
assert preregistered_migrations[0].number == 1
|
||||
assert preregistered_migrations[1].number == 2
|
||||
assert preregistered_migrations[2].number == 3
|
||||
@@ -175,4 +175,4 @@ class TestPreregisteredStages:
|
||||
pass
|
||||
|
||||
for key in preregistered_stages:
|
||||
assert isinstance(key, str)
|
||||
assert isinstance(key, str)
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
|
||||
Note: Uses import_isolation to break circular import chains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -19,15 +20,17 @@ from typing import Generator
|
||||
|
||||
class MockLifecycleControlScopeEnum:
|
||||
"""Mock enum value for LifecycleControlScope with .value attribute."""
|
||||
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
def __repr__(self):
|
||||
return f"LifecycleControlScope.{self.value.upper()}"
|
||||
return f'LifecycleControlScope.{self.value.upper()}'
|
||||
|
||||
|
||||
class MockLifecycleControlScope:
|
||||
"""Mock enum for LifecycleControlScope."""
|
||||
|
||||
APPLICATION = MockLifecycleControlScopeEnum('application')
|
||||
PLATFORM = MockLifecycleControlScopeEnum('platform')
|
||||
PIPELINE = MockLifecycleControlScopeEnum('pipeline')
|
||||
@@ -40,17 +43,17 @@ def isolated_taskmgr_import() -> Generator[None, None, None]:
|
||||
# Mock modules that cause circular imports
|
||||
mock_entities = MagicMock()
|
||||
mock_entities.LifecycleControlScope = MockLifecycleControlScope
|
||||
|
||||
|
||||
mock_app = MagicMock()
|
||||
|
||||
|
||||
mock_importutil = MagicMock()
|
||||
mock_importutil.import_modules_in_pkg = lambda pkg: None
|
||||
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
|
||||
|
||||
|
||||
mock_http_controller = MagicMock()
|
||||
|
||||
|
||||
mock_rag_mgr = MagicMock()
|
||||
|
||||
|
||||
mocks = {
|
||||
'langbot.pkg.core.entities': mock_entities,
|
||||
'langbot.pkg.core.app': mock_app,
|
||||
@@ -58,26 +61,26 @@ def isolated_taskmgr_import() -> Generator[None, None, None]:
|
||||
'langbot.pkg.rag.knowledge.kbmgr': mock_rag_mgr,
|
||||
'langbot.pkg.utils.importutil': mock_importutil,
|
||||
}
|
||||
|
||||
|
||||
# Save original state
|
||||
saved = {}
|
||||
for name in mocks:
|
||||
if name in sys.modules:
|
||||
saved[name] = sys.modules[name]
|
||||
|
||||
|
||||
# Clear taskmgr to force re-import
|
||||
taskmgr_name = 'langbot.pkg.core.taskmgr'
|
||||
if taskmgr_name in sys.modules:
|
||||
saved[taskmgr_name] = sys.modules[taskmgr_name]
|
||||
|
||||
|
||||
try:
|
||||
# Apply mocks
|
||||
for name, module in mocks.items():
|
||||
sys.modules[name] = module
|
||||
|
||||
|
||||
# Clear taskmgr
|
||||
sys.modules.pop(taskmgr_name, None)
|
||||
|
||||
|
||||
yield
|
||||
finally:
|
||||
# Restore
|
||||
@@ -86,7 +89,7 @@ def isolated_taskmgr_import() -> Generator[None, None, None]:
|
||||
sys.modules[name] = saved[name]
|
||||
else:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
if taskmgr_name in saved:
|
||||
sys.modules[taskmgr_name] = saved[taskmgr_name]
|
||||
else:
|
||||
@@ -97,6 +100,7 @@ def get_taskmgr_classes():
|
||||
"""Get TaskContext, TaskWrapper, AsyncTaskManager classes."""
|
||||
with isolated_taskmgr_import():
|
||||
from langbot.pkg.core.taskmgr import TaskContext, TaskWrapper, AsyncTaskManager
|
||||
|
||||
return TaskContext, TaskWrapper, AsyncTaskManager
|
||||
|
||||
|
||||
@@ -194,9 +198,10 @@ class TestTaskContext:
|
||||
"""Test TaskContext.placeholder() returns singleton."""
|
||||
with isolated_taskmgr_import():
|
||||
from langbot.pkg.core.taskmgr import TaskContext
|
||||
|
||||
|
||||
# Reset global placeholder
|
||||
import langbot.pkg.core.taskmgr as taskmgr_module
|
||||
|
||||
taskmgr_module.placeholder_context = None
|
||||
|
||||
ctx1 = TaskContext.placeholder()
|
||||
@@ -269,7 +274,8 @@ class TestTaskWrapper:
|
||||
return 'result'
|
||||
|
||||
wrapper = TaskWrapper(
|
||||
mock_app, immediate_coro(),
|
||||
mock_app,
|
||||
immediate_coro(),
|
||||
name='test_task',
|
||||
label='Test Task',
|
||||
)
|
||||
@@ -414,7 +420,7 @@ class TestAsyncTaskManager:
|
||||
async def test_cancel_by_scope(self):
|
||||
"""Test cancel_by_scope cancels matching tasks."""
|
||||
_, _, AsyncTaskManager = get_taskmgr_classes()
|
||||
|
||||
|
||||
mock_app = create_mock_app()
|
||||
manager = AsyncTaskManager(mock_app)
|
||||
|
||||
@@ -422,16 +428,10 @@ class TestAsyncTaskManager:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Create task with APPLICATION scope
|
||||
w1 = manager.create_task(
|
||||
long_coro(),
|
||||
scopes=[MockLifecycleControlScope.APPLICATION]
|
||||
)
|
||||
w1 = manager.create_task(long_coro(), scopes=[MockLifecycleControlScope.APPLICATION])
|
||||
|
||||
# Create task with different scope
|
||||
w2 = manager.create_task(
|
||||
long_coro(),
|
||||
scopes=[MockLifecycleControlScope.PIPELINE]
|
||||
)
|
||||
w2 = manager.create_task(long_coro(), scopes=[MockLifecycleControlScope.PIPELINE])
|
||||
|
||||
manager.cancel_by_scope(MockLifecycleControlScope.APPLICATION)
|
||||
|
||||
|
||||
@@ -15,68 +15,68 @@ class TestI18nString:
|
||||
|
||||
def test_create_with_english_only(self):
|
||||
"""Create I18nString with only English."""
|
||||
i18n = I18nString(en_US="Hello")
|
||||
i18n = I18nString(en_US='Hello')
|
||||
|
||||
assert i18n.en_US == "Hello"
|
||||
assert i18n.en_US == 'Hello'
|
||||
assert i18n.zh_Hans is None
|
||||
|
||||
def test_create_with_multiple_languages(self):
|
||||
"""Create I18nString with multiple languages."""
|
||||
i18n = I18nString(
|
||||
en_US="Hello",
|
||||
zh_Hans="你好",
|
||||
zh_Hant="你好",
|
||||
ja_JP="こんにちは",
|
||||
en_US='Hello',
|
||||
zh_Hans='你好',
|
||||
zh_Hant='你好',
|
||||
ja_JP='こんにちは',
|
||||
)
|
||||
|
||||
assert i18n.en_US == "Hello"
|
||||
assert i18n.zh_Hans == "你好"
|
||||
assert i18n.zh_Hant == "你好"
|
||||
assert i18n.ja_JP == "こんにちは"
|
||||
assert i18n.en_US == 'Hello'
|
||||
assert i18n.zh_Hans == '你好'
|
||||
assert i18n.zh_Hant == '你好'
|
||||
assert i18n.ja_JP == 'こんにちは'
|
||||
|
||||
def test_to_dict_with_english_only(self):
|
||||
"""to_dict returns only non-None fields."""
|
||||
i18n = I18nString(en_US="Hello")
|
||||
i18n = I18nString(en_US='Hello')
|
||||
|
||||
result = i18n.to_dict()
|
||||
|
||||
assert result == {"en_US": "Hello"}
|
||||
assert result == {'en_US': 'Hello'}
|
||||
|
||||
def test_to_dict_with_multiple_languages(self):
|
||||
"""to_dict returns all non-None fields."""
|
||||
i18n = I18nString(
|
||||
en_US="Hello",
|
||||
zh_Hans="你好",
|
||||
en_US='Hello',
|
||||
zh_Hans='你好',
|
||||
)
|
||||
|
||||
result = i18n.to_dict()
|
||||
|
||||
assert result == {"en_US": "Hello", "zh_Hans": "你好"}
|
||||
assert result == {'en_US': 'Hello', 'zh_Hans': '你好'}
|
||||
|
||||
def test_to_dict_excludes_none(self):
|
||||
"""to_dict excludes None values."""
|
||||
i18n = I18nString(
|
||||
en_US="Hello",
|
||||
en_US='Hello',
|
||||
zh_Hans=None,
|
||||
ja_JP="こんにちは",
|
||||
ja_JP='こんにちは',
|
||||
)
|
||||
|
||||
result = i18n.to_dict()
|
||||
|
||||
assert "zh_Hans" not in result
|
||||
assert "en_US" in result
|
||||
assert "ja_JP" in result
|
||||
assert 'zh_Hans' not in result
|
||||
assert 'en_US' in result
|
||||
assert 'ja_JP' in result
|
||||
|
||||
def test_to_dict_all_languages(self):
|
||||
"""to_dict with all supported languages."""
|
||||
i18n = I18nString(
|
||||
en_US="Hello",
|
||||
zh_Hans="你好",
|
||||
zh_Hant="你好",
|
||||
ja_JP="こんにちは",
|
||||
th_TH="สวัสดี",
|
||||
vi_VN="Xin chào",
|
||||
es_ES="Hola",
|
||||
en_US='Hello',
|
||||
zh_Hans='你好',
|
||||
zh_Hant='你好',
|
||||
ja_JP='こんにちは',
|
||||
th_TH='สวัสดี',
|
||||
vi_VN='Xin chào',
|
||||
es_ES='Hola',
|
||||
)
|
||||
|
||||
result = i18n.to_dict()
|
||||
@@ -92,30 +92,30 @@ class TestMetadata:
|
||||
from langbot.pkg.discover.engine import I18nString
|
||||
|
||||
metadata = Metadata(
|
||||
name="test-component",
|
||||
label=I18nString(en_US="Test Component"),
|
||||
name='test-component',
|
||||
label=I18nString(en_US='Test Component'),
|
||||
)
|
||||
|
||||
assert metadata.name == "test-component"
|
||||
assert metadata.label.en_US == "Test Component"
|
||||
assert metadata.name == 'test-component'
|
||||
assert metadata.label.en_US == 'Test Component'
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
"""Create Metadata with all optional fields."""
|
||||
from langbot.pkg.discover.engine import I18nString
|
||||
|
||||
metadata = Metadata(
|
||||
name="test-component",
|
||||
label=I18nString(en_US="Test"),
|
||||
description=I18nString(en_US="A test component"),
|
||||
version="1.0.0",
|
||||
icon="test-icon",
|
||||
author="Test Author",
|
||||
repository="https://github.com/test/repo",
|
||||
name='test-component',
|
||||
label=I18nString(en_US='Test'),
|
||||
description=I18nString(en_US='A test component'),
|
||||
version='1.0.0',
|
||||
icon='test-icon',
|
||||
author='Test Author',
|
||||
repository='https://github.com/test/repo',
|
||||
)
|
||||
|
||||
assert metadata.version == "1.0.0"
|
||||
assert metadata.icon == "test-icon"
|
||||
assert metadata.author == "Test Author"
|
||||
assert metadata.version == '1.0.0'
|
||||
assert metadata.icon == 'test-icon'
|
||||
assert metadata.author == 'Test Author'
|
||||
|
||||
|
||||
class TestComponentManifest:
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
|
||||
Note: Uses import isolation to break circular import chains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
@@ -86,6 +87,7 @@ def get_database_module():
|
||||
"""Get database module with import isolation."""
|
||||
with isolated_database_import():
|
||||
from langbot.pkg.persistence import database
|
||||
|
||||
return database
|
||||
|
||||
|
||||
@@ -198,4 +200,4 @@ class TestManagerClassDecorator:
|
||||
# Create instance to test method (with mock app)
|
||||
mock_app = Mock()
|
||||
instance = ManagerWithMethods(mock_app)
|
||||
assert instance.custom_method() == 'test_value'
|
||||
assert instance.custom_method() == 'test_value'
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests cover:
|
||||
- execute_async() with mock database
|
||||
- get_db_engine() with mock database manager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -85,7 +86,7 @@ class TestExecuteAsync:
|
||||
mock_db.get_engine = Mock(return_value=mock_engine)
|
||||
mgr.db = mock_db
|
||||
|
||||
result = await mgr.execute_async(sqlalchemy.text("SELECT 1"))
|
||||
result = await mgr.execute_async(sqlalchemy.text('SELECT 1'))
|
||||
|
||||
# Verify result is the same object returned by execute
|
||||
assert result is mock_result
|
||||
@@ -152,4 +153,4 @@ class TestSerializeModelEdgeCases:
|
||||
result = mgr.serialize_model(SimpleModel, instance, masked_columns=['id', 'name'])
|
||||
|
||||
# Result should be empty dict when all columns masked
|
||||
assert result == {}
|
||||
assert result == {}
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests cover:
|
||||
- datetime conversion to isoformat
|
||||
- masked_columns exclusion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
@@ -12,6 +12,12 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
# Preload pipelinemgr so the pipeline.stage module is fully initialised before
|
||||
# any individual stage test (e.g. preproc, longtext) tries to import it. Without
|
||||
# this, running a stage test in isolation triggers a circular-import error:
|
||||
# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound).
|
||||
import langbot.pkg.pipeline.pipelinemgr # noqa: F401
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
@@ -34,6 +40,9 @@ class MockApplication:
|
||||
self.query_pool = self._create_mock_query_pool()
|
||||
self.instance_config = self._create_mock_instance_config()
|
||||
self.task_mgr = self._create_mock_task_manager()
|
||||
# Skill manager is optional; PreProcessor only touches it for the
|
||||
# local-agent runner. None keeps the skill-binding branch inert.
|
||||
self.skill_mgr = None
|
||||
|
||||
def _create_mock_logger(self):
|
||||
logger = Mock()
|
||||
|
||||
@@ -49,7 +49,7 @@ class TestPendingMessage:
|
||||
"""PendingMessage should be created with correct fields."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -88,7 +88,7 @@ class TestSessionBuffer:
|
||||
"""SessionBuffer should accept initial messages."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -309,7 +309,7 @@ class TestMessageAggregatorAddMessage:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -348,7 +348,7 @@ class TestMessageAggregatorAddMessage:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestMessageAggregatorAddMessage:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -419,7 +419,7 @@ class TestMessageAggregatorMerge:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -445,8 +445,8 @@ class TestMessageAggregatorMerge:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain1 = text_chain("hello")
|
||||
chain2 = text_chain("world")
|
||||
chain1 = text_chain('hello')
|
||||
chain2 = text_chain('world')
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -476,8 +476,8 @@ class TestMessageAggregatorMerge:
|
||||
|
||||
# Should contain both messages with separator
|
||||
merged_str = str(merged.message_chain)
|
||||
assert "hello" in merged_str
|
||||
assert "world" in merged_str
|
||||
assert 'hello' in merged_str
|
||||
assert 'world' in merged_str
|
||||
|
||||
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
|
||||
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
|
||||
@@ -486,8 +486,8 @@ class TestMessageAggregatorMerge:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain1 = text_chain("first")
|
||||
chain2 = text_chain("second")
|
||||
chain1 = text_chain('first')
|
||||
chain2 = text_chain('second')
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -545,7 +545,7 @@ class TestMessageAggregatorFlush:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
@@ -597,7 +597,7 @@ class TestMessageAggregatorFlushAll:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
chain = text_chain('hello')
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from tests.factories import FakeApp
|
||||
|
||||
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
"""
|
||||
@@ -36,9 +37,11 @@ def mock_circular_import_chain():
|
||||
# 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')
|
||||
|
||||
@@ -70,9 +73,12 @@ def mock_event_ctx():
|
||||
@pytest.fixture
|
||||
def set_runner():
|
||||
"""Factory fixture to set a custom runner for tests."""
|
||||
|
||||
def _set_runner(runner_class):
|
||||
import sys
|
||||
|
||||
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class]
|
||||
|
||||
return _set_runner
|
||||
|
||||
|
||||
@@ -87,6 +93,7 @@ def get_chat_handler():
|
||||
global _chat_handler_module
|
||||
if _chat_handler_module is None:
|
||||
from importlib import import_module
|
||||
|
||||
_chat_handler_module = import_module('langbot.pkg.pipeline.process.handlers.chat')
|
||||
return _chat_handler_module
|
||||
|
||||
@@ -96,12 +103,14 @@ def get_entities():
|
||||
global _entities_module
|
||||
if _entities_module is None:
|
||||
from importlib import import_module
|
||||
|
||||
_entities_module = import_module('langbot.pkg.pipeline.entities')
|
||||
return _entities_module
|
||||
|
||||
|
||||
# ============== REAL ChatMessageHandler Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestChatMessageHandlerReal:
|
||||
"""Tests for real ChatMessageHandler class."""
|
||||
@@ -188,9 +197,11 @@ class TestChatMessageHandlerReal:
|
||||
|
||||
class QuickRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
yield Message(role='assistant', content='ok')
|
||||
|
||||
@@ -222,9 +233,11 @@ class TestChatMessageHandlerReal:
|
||||
|
||||
class SingleRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
yield Message(role='assistant', content='response')
|
||||
|
||||
@@ -262,9 +275,11 @@ class TestChatHandlerStreaming:
|
||||
|
||||
class StreamRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
yield MessageChunk(role='assistant', content='Hello', is_final=False)
|
||||
yield MessageChunk(role='assistant', content=' World', is_final=True)
|
||||
@@ -303,14 +318,19 @@ class TestChatHandlerExceptions:
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
|
||||
},
|
||||
}
|
||||
|
||||
class FailingRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
raise ValueError('API error')
|
||||
yield
|
||||
@@ -346,14 +366,19 @@ class TestChatHandlerExceptions:
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'show-error'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
|
||||
},
|
||||
}
|
||||
|
||||
class ErrorRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
raise ValueError('Custom error')
|
||||
yield
|
||||
@@ -386,14 +411,19 @@ class TestChatHandlerExceptions:
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'hide'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
|
||||
},
|
||||
}
|
||||
|
||||
class HideErrorRunner:
|
||||
name = 'local-agent'
|
||||
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def run(self, query):
|
||||
raise RuntimeError('hidden')
|
||||
yield
|
||||
@@ -433,4 +463,4 @@ class TestChatHandlerHelper:
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
result = handler.cut_str('first line\nsecond line')
|
||||
assert '...' in result
|
||||
assert '...' in result
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
# TODO: unskip once the handler ↔ app circular import is resolved
|
||||
pytest.skip(
|
||||
'circular import in handler ↔ app; will be unblocked once resolved',
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from langbot.pkg.pipeline.process.handler import MessageHandler # noqa: E402
|
||||
|
||||
|
||||
class _StubHandler(MessageHandler):
|
||||
async def handle(self, query):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
handler = _StubHandler(ap=Mock())
|
||||
|
||||
|
||||
def test_chat_handler_formats_tool_call_request_log():
|
||||
result = provider_message.Message(
|
||||
role='assistant',
|
||||
content='',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='{}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
summary = handler.format_result_log(result)
|
||||
|
||||
assert summary == 'assistant: requested tools: exec'
|
||||
|
||||
|
||||
def test_chat_handler_formats_tool_result_log():
|
||||
result = provider_message.Message(
|
||||
role='tool',
|
||||
content='{"status":"completed","exit_code":0,"backend":"podman","stdout":"42\\n"}',
|
||||
tool_call_id='call-1',
|
||||
)
|
||||
|
||||
summary = handler.format_result_log(result)
|
||||
|
||||
# Tool results use generic cut_str truncation
|
||||
assert summary is not None
|
||||
assert summary.startswith('tool: {"status":"com')
|
||||
assert summary.endswith('...')
|
||||
|
||||
|
||||
def test_chat_handler_formats_tool_error_log():
|
||||
result = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
content='err: host_path must point to an existing directory on the host',
|
||||
tool_call_id='call-1',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
summary = handler.format_result_log(result)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.startswith('tool error: err: host_path must')
|
||||
assert summary.endswith('...')
|
||||
|
||||
|
||||
def test_chat_handler_skips_empty_assistant_log():
|
||||
result = provider_message.Message(role='assistant', content='')
|
||||
|
||||
summary = handler.format_result_log(result)
|
||||
|
||||
assert summary is None
|
||||
@@ -67,7 +67,11 @@ def make_pipeline_config(**overrides):
|
||||
for key, value in overrides.items():
|
||||
if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict):
|
||||
for sub_key, sub_value in value.items():
|
||||
if sub_key in base_config[key] and isinstance(base_config[key][sub_key], dict) and isinstance(sub_value, dict):
|
||||
if (
|
||||
sub_key in base_config[key]
|
||||
and isinstance(base_config[key][sub_key], dict)
|
||||
and isinstance(sub_value, dict)
|
||||
):
|
||||
base_config[key][sub_key].update(sub_value)
|
||||
else:
|
||||
base_config[key][sub_key] = sub_value
|
||||
@@ -141,7 +145,7 @@ class TestPreContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello world")
|
||||
query = text_query('hello world')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -163,7 +167,7 @@ class TestPreContentFilter:
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Empty message chain
|
||||
query = text_query("")
|
||||
query = text_query('')
|
||||
query.message_chain = platform_message.MessageChain([])
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
@@ -185,7 +189,7 @@ class TestPreContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query(" ") # Only whitespace
|
||||
query = text_query(' ') # Only whitespace
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -234,7 +238,7 @@ class TestPreContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello world")
|
||||
query = text_query('hello world')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -266,7 +270,7 @@ class TestContentIgnoreFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("/help me")
|
||||
query = text_query('/help me')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -294,7 +298,7 @@ class TestContentIgnoreFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("http://example.com")
|
||||
query = text_query('http://example.com')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -322,7 +326,7 @@ class TestContentIgnoreFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("normal message")
|
||||
query = text_query('normal message')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -343,7 +347,7 @@ class TestContentIgnoreFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("/help me")
|
||||
query = text_query('/help me')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
@@ -368,12 +372,10 @@ class TestPostContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
# Add a response message
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='Hello back!')
|
||||
]
|
||||
query.resp_messages = [provider_message.Message(role='assistant', content='Hello back!')]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
@@ -398,11 +400,9 @@ class TestPostContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='Response')
|
||||
]
|
||||
query.resp_messages = [provider_message.Message(role='assistant', content='Response')]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
@@ -422,7 +422,7 @@ class TestPostContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
# Non-string content - use model_construct to bypass validation
|
||||
# The actual content type could be a list of ContentElement objects
|
||||
@@ -450,11 +450,9 @@ class TestPostContentFilter:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='')
|
||||
]
|
||||
query.resp_messages = [provider_message.Message(role='assistant', content='')]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
@@ -476,7 +474,7 @@ class TestContentFilterStageInvalidName:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
with pytest.raises(ValueError, match='未知的 stage_inst_name'):
|
||||
@@ -506,7 +504,7 @@ class TestContentIgnoreFilterDirect:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("normal message without prefix")
|
||||
query = text_query('normal message without prefix')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
@@ -15,6 +15,7 @@ from tests.factories import FakeApp, command_query
|
||||
|
||||
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
"""
|
||||
@@ -56,6 +57,7 @@ def mock_event_ctx():
|
||||
@pytest.fixture
|
||||
def mock_execute_factory():
|
||||
"""Factory fixture to create mock cmd_mgr.execute generators."""
|
||||
|
||||
def _create_execute(
|
||||
text: str | None = 'ok',
|
||||
error: str | None = None,
|
||||
@@ -71,7 +73,9 @@ def mock_execute_factory():
|
||||
ret.image_base64 = image_base64
|
||||
ret.file_url = file_url
|
||||
yield ret
|
||||
|
||||
return mock_execute
|
||||
|
||||
return _create_execute
|
||||
|
||||
|
||||
@@ -86,6 +90,7 @@ def get_command_handler():
|
||||
global _command_handler_module
|
||||
if _command_handler_module is None:
|
||||
from importlib import import_module
|
||||
|
||||
_command_handler_module = import_module('langbot.pkg.pipeline.process.handlers.command')
|
||||
return _command_handler_module
|
||||
|
||||
@@ -95,12 +100,14 @@ def get_entities():
|
||||
global _entities_module
|
||||
if _entities_module is None:
|
||||
from importlib import import_module
|
||||
|
||||
_entities_module = import_module('langbot.pkg.pipeline.entities')
|
||||
return _entities_module
|
||||
|
||||
|
||||
# ============== REAL CommandHandler Tests ==============
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestCommandHandlerReal:
|
||||
"""Tests for real CommandHandler class."""
|
||||
@@ -127,6 +134,7 @@ class TestCommandHandlerReal:
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
executed_commands = []
|
||||
|
||||
async def track_execute(command_text, full_command_text, query, session):
|
||||
executed_commands.append(command_text)
|
||||
ret = Mock()
|
||||
@@ -334,8 +342,7 @@ class TestCommandHandlerReal:
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory(
|
||||
text='Here is the image:',
|
||||
image_url='https://example.com/image.png'
|
||||
text='Here is the image:', image_url='https://example.com/image.png'
|
||||
)
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
@@ -393,4 +400,4 @@ class TestCommandHandlerHelper:
|
||||
command = get_command_handler()
|
||||
handler = command.CommandHandler(fake_app)
|
||||
result = handler.cut_str('first line\nsecond line')
|
||||
assert '...' in result
|
||||
assert '...' in result
|
||||
|
||||
@@ -126,11 +126,9 @@ class TestLongTextProcessStageProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="very long response")])
|
||||
]
|
||||
query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='very long response')])]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
@@ -151,11 +149,9 @@ class TestLongTextProcessStageProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="short response")])
|
||||
]
|
||||
query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='short response')])]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
@@ -179,14 +175,13 @@ class TestLongTextProcessStageProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
# Non-Plain component (Image)
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([
|
||||
platform_message.Plain(text="short"),
|
||||
platform_message.Image(url="https://example.com/img.png")
|
||||
])
|
||||
platform_message.MessageChain(
|
||||
[platform_message.Plain(text='short'), platform_message.Image(url='https://example.com/img.png')]
|
||||
)
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
@@ -213,7 +208,7 @@ class TestLongTextProcessStageProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -232,7 +227,7 @@ class TestLongTextProcessStageProcess:
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
stage.strategy_impl = AsyncMock()
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_longtext_config(strategy='forward', threshold=1)
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -242,6 +237,7 @@ class TestLongTextProcessStageProcess:
|
||||
assert result.new_query is query
|
||||
stage.strategy_impl.process.assert_not_called()
|
||||
|
||||
|
||||
class TestForwardStrategy:
|
||||
"""Tests for ForwardComponentStrategy."""
|
||||
|
||||
@@ -260,7 +256,7 @@ class TestForwardStrategy:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
# Create a mock adapter with bot_account_id
|
||||
mock_adapter = Mock()
|
||||
@@ -268,10 +264,8 @@ class TestForwardStrategy:
|
||||
query.adapter = mock_adapter
|
||||
|
||||
# Long text exceeding threshold
|
||||
long_text = "This is a very long response that exceeds the threshold"
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=long_text)])
|
||||
]
|
||||
long_text = 'This is a very long response that exceeds the threshold'
|
||||
query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text=long_text)])]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
@@ -297,13 +291,13 @@ class TestForwardStrategy:
|
||||
|
||||
await strat.initialize()
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_longtext_config()
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.bot_account_id = '12345'
|
||||
query.adapter = mock_adapter
|
||||
|
||||
components = await strat.process("test message", query)
|
||||
components = await strat.process('test message', query)
|
||||
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
@@ -326,14 +320,12 @@ class TestLongTextThreshold:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
# Text below threshold
|
||||
short_text = "x" * (threshold - 1)
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=short_text)])
|
||||
]
|
||||
short_text = 'x' * (threshold - 1)
|
||||
query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text=short_text)])]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestRoundTruncatorProcess:
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Create query with 3 messages (within limit)
|
||||
query = text_query("current message")
|
||||
query = text_query('current message')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='message 1'),
|
||||
@@ -154,7 +154,7 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
# Create query with many messages exceeding limit
|
||||
# 7 messages = 3 full rounds + 1 current user
|
||||
query = text_query("current message")
|
||||
query = text_query('current message')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='message 1'),
|
||||
@@ -194,7 +194,7 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = []
|
||||
|
||||
@@ -216,7 +216,7 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='hello'),
|
||||
@@ -240,7 +240,7 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("current")
|
||||
query = text_query('current')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='user1'),
|
||||
@@ -274,7 +274,7 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("current")
|
||||
query = text_query('current')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='old1'),
|
||||
@@ -305,7 +305,7 @@ class TestRoundTruncatorDirect:
|
||||
trun = trun_cls(app)
|
||||
break
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_truncate_config(max_round=3)
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='m1'),
|
||||
|
||||
@@ -14,33 +14,43 @@ import json
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
# Break the circular import chain before importing n8nsvapi:
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
# Break the circular import chain while importing n8nsvapi:
|
||||
# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init)
|
||||
_mock_runner = MagicMock()
|
||||
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_mock_runner.RequestRunner = object
|
||||
_mocked_imports = {
|
||||
'langbot.pkg.provider.runner': _mock_runner,
|
||||
# The stubs are restored in a ``finally`` block so this module does NOT pollute
|
||||
# sys.modules for other test modules (e.g. ones importing the real
|
||||
# LocalAgentRunner, which would otherwise inherit ``object`` and break).
|
||||
# Mirrors master's intent but uses try/finally so a raised import doesn't
|
||||
# leave the global namespace in a stubbed state, and includes
|
||||
# ``langbot.pkg.utils.httpclient`` which master didn't stub.
|
||||
_runner_stub = MagicMock()
|
||||
_runner_stub.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_runner_stub.RequestRunner = object
|
||||
_import_stubs = {
|
||||
'langbot.pkg.provider.runner': _runner_stub,
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot.pkg.utils.httpclient': MagicMock(),
|
||||
}
|
||||
_original_imports = {name: sys.modules.get(name) for name in _mocked_imports}
|
||||
sys.modules.update(_mocked_imports)
|
||||
|
||||
import pytest # noqa: E402
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402
|
||||
|
||||
for _name, _original in _original_imports.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _original
|
||||
_saved_modules = {name: sys.modules.get(name) for name in _import_stubs}
|
||||
for _name, _stub in _import_stubs.items():
|
||||
sys.modules[_name] = _stub
|
||||
try:
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
|
||||
finally:
|
||||
for _name, _original in _saved_modules.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
@@ -83,6 +93,7 @@ async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str])
|
||||
# _process_response: stream format (type:item/end)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_single_item():
|
||||
"""Single item + end in one chunk yields final chunk with full content."""
|
||||
@@ -165,6 +176,7 @@ async def test_stream_format_no_spurious_empty_yield():
|
||||
# _process_response: plain JSON fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_json_with_output_key():
|
||||
"""Plain JSON with matching output_key extracts value via output_key."""
|
||||
@@ -235,6 +247,7 @@ async def test_invalid_json_returns_raw_text():
|
||||
# _call_webhook: output type depends on is_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_query(is_stream: bool):
|
||||
"""Build a minimal Query mock."""
|
||||
query = Mock()
|
||||
|
||||
@@ -162,3 +162,46 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
|
||||
# Verify stage was called
|
||||
mock_stage.process.assert_called_once()
|
||||
|
||||
|
||||
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
|
||||
"""Local Agent resource selection should override legacy extension prefs."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
|
||||
'mcp-resource-agent-read-enabled': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
|
||||
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
|
||||
"""Existing extension prefs remain compatible until a Local Agent value exists."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {'ai': {'local-agent': {}}}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -78,7 +79,7 @@ class TestPreProcessorNormalText:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello world")
|
||||
query = text_query('hello world')
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -113,7 +114,7 @@ class TestPreProcessorNormalText:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("test message")
|
||||
query = text_query('test message')
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -194,13 +195,16 @@ class TestPreProcessorImageSegment:
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
# Image query with base64
|
||||
query = image_query(text="look at this", url=None)
|
||||
query = image_query(text='look at this', url=None)
|
||||
# Set base64 on the image component
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
chain = platform_message.MessageChain([
|
||||
platform_message.Plain(text="look at this"),
|
||||
platform_message.Image(base64="data:image/png;base64,abc123"),
|
||||
])
|
||||
|
||||
chain = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='look at this'),
|
||||
platform_message.Image(base64='data:image/png;base64,abc123'),
|
||||
]
|
||||
)
|
||||
query.message_chain = chain
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
@@ -238,7 +242,7 @@ class TestPreProcessorImageSegment:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = image_query(text="describe this")
|
||||
query = image_query(text='describe this')
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -276,7 +280,7 @@ class TestPreProcessorModelSelection:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
|
||||
# Set pipeline config with primary model
|
||||
query.pipeline_config = {
|
||||
@@ -335,7 +339,7 @@ class TestPreProcessorModelSelection:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
@@ -384,7 +388,7 @@ class TestPreProcessorVariables:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello", sender_id=67890)
|
||||
query = text_query('hello', sender_id=67890)
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -421,10 +425,67 @@ class TestPreProcessorVariables:
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = group_text_query("hello", group_id=99999)
|
||||
query = group_text_query('hello', group_id=99999)
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
variables = result.new_query.variables
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
|
||||
|
||||
class TestPreProcessorToolSelection:
|
||||
"""Tests for Local Agent tool selection."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_agent_filters_selected_tools(self):
|
||||
"""Only selected tools should be exposed when all-tools mode is off."""
|
||||
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)
|
||||
|
||||
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=[
|
||||
SimpleNamespace(name='exec'),
|
||||
SimpleNamespace(name='plugin_tool'),
|
||||
SimpleNamespace(name='mcp_tool'),
|
||||
]
|
||||
)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
'enable-all-tools': False,
|
||||
'tools': ['plugin_tool'],
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
|
||||
|
||||
@@ -46,7 +46,7 @@ class TestFixedWindowAlgo:
|
||||
'safety': {
|
||||
'rate-limit': {
|
||||
'window-length': 60, # 60 seconds window
|
||||
'limitation': 10, # 10 requests per window
|
||||
'limitation': 10, # 10 requests per window
|
||||
'strategy': 'drop',
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,9 @@ class TestFixedWindowAlgo:
|
||||
# Make requests within limit
|
||||
for i in range(10):
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345'
|
||||
)
|
||||
assert result is True, f"Request {i+1} should be allowed"
|
||||
assert result is True, f'Request {i + 1} should be allowed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_exceeds_limit_drop_strategy(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
@@ -91,20 +89,12 @@ class TestFixedWindowAlgo:
|
||||
|
||||
# Exhaust the limit
|
||||
for i in range(10):
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
|
||||
|
||||
# Next request should be denied
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
result = await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
|
||||
|
||||
assert result is False, "Request exceeding limit should be denied"
|
||||
assert result is False, 'Request exceeding limit should be denied'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_different_sessions_isolated(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
@@ -116,20 +106,14 @@ class TestFixedWindowAlgo:
|
||||
|
||||
# Exhaust limit for session 1
|
||||
for i in range(10):
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'session1'
|
||||
)
|
||||
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, 'session1')
|
||||
|
||||
# Session 2 should still have its own limit
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'session2'
|
||||
sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, 'session2'
|
||||
)
|
||||
|
||||
assert result is True, "Different session should have independent limit"
|
||||
assert result is True, 'Different session should have independent limit'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_limit_one_request(self, mock_app_for_algo, sample_query):
|
||||
@@ -150,19 +134,11 @@ class TestFixedWindowAlgo:
|
||||
await algo.initialize()
|
||||
|
||||
# First request allowed
|
||||
result1 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
result1 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, '12345')
|
||||
assert result1 is True
|
||||
|
||||
# Second request denied
|
||||
result2 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
result2 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, '12345')
|
||||
assert result2 is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -174,11 +150,7 @@ class TestFixedWindowAlgo:
|
||||
await algo.initialize()
|
||||
|
||||
# First request creates container
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
|
||||
|
||||
# Key format: 'LauncherTypes.PERSON_12345' (enum string representation)
|
||||
expected_key = 'LauncherTypes.PERSON_12345'
|
||||
@@ -230,7 +202,7 @@ class TestFixedWindowAlgo:
|
||||
|
||||
# New request should be allowed (new window)
|
||||
result = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
|
||||
assert result is True, "New window should allow new requests"
|
||||
assert result is True, 'New window should allow new requests'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_wait_strategy_blocks_until_next_window(self, mock_app_for_algo, sample_query):
|
||||
@@ -256,29 +228,21 @@ class TestFixedWindowAlgo:
|
||||
|
||||
# First request allowed
|
||||
start_time = time.time()
|
||||
result1 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'wait_test'
|
||||
)
|
||||
result1 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test')
|
||||
assert result1 is True
|
||||
|
||||
# Exhaust limit
|
||||
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test')
|
||||
|
||||
# Third request should wait and then succeed
|
||||
result3 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'wait_test'
|
||||
)
|
||||
result3 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test')
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
assert result3 is True, "After wait, request should succeed"
|
||||
assert result3 is True, 'After wait, request should succeed'
|
||||
# Should have waited approximately until next window
|
||||
# With 1-second window, elapsed should be > 0.5 second (allowing for timing variance)
|
||||
# Note: This is a timing-sensitive test, so we use a generous tolerance
|
||||
assert elapsed >= 0.5, f"Should have waited for next window, elapsed={elapsed:.2f}s"
|
||||
assert elapsed >= 0.5, f'Should have waited for next window, elapsed={elapsed:.2f}s'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_release_access(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
@@ -289,11 +253,7 @@ class TestFixedWindowAlgo:
|
||||
await algo.initialize()
|
||||
|
||||
# release_access is empty in current implementation
|
||||
await algo.release_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
await algo.release_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
|
||||
|
||||
# Should not raise or change state
|
||||
assert 'person_12345' not in algo.containers
|
||||
|
||||
@@ -36,6 +36,11 @@ def get_entities_module():
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def get_plugin_diagnostics_module():
|
||||
"""Lazy import for plugin diagnostic attribution helpers."""
|
||||
return import_module('langbot.pkg.pipeline.plugin_diagnostics')
|
||||
|
||||
|
||||
def make_wrapper_config():
|
||||
"""Create a pipeline config for wrapper tests."""
|
||||
return {
|
||||
@@ -55,7 +60,7 @@ def make_session():
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
use_prompt_name="default",
|
||||
use_prompt_name='default',
|
||||
using_conversation=None,
|
||||
conversations=[],
|
||||
)
|
||||
@@ -93,11 +98,9 @@ class TestResponseWrapperMessageChain:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="response")])
|
||||
]
|
||||
query.resp_messages = [platform_message.MessageChain([platform_message.Plain(text='response')])]
|
||||
query.resp_message_chain = []
|
||||
|
||||
results = []
|
||||
@@ -108,6 +111,45 @@ class TestResponseWrapperMessageChain:
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
assert len(results[0].new_query.resp_message_chain) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
|
||||
"""MessageChain replies from earlier plugin events keep attribution."""
|
||||
wrapper = get_wrapper_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
await stage.initialize(make_wrapper_config())
|
||||
|
||||
reply_chain = platform_message.MessageChain([platform_message.Plain(text='response')])
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_wrapper_config()
|
||||
query.resp_messages = [reply_chain]
|
||||
query.resp_message_chain = []
|
||||
plugin_diagnostics = get_plugin_diagnostics_module()
|
||||
plugin_diagnostics.record_pending_plugin_response_source(
|
||||
query,
|
||||
reply_chain,
|
||||
[
|
||||
{
|
||||
'kind': 'reply_message_chain',
|
||||
'plugin': {'author': 'tester', 'name': 'demo'},
|
||||
}
|
||||
],
|
||||
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
|
||||
'PersonNormalMessageReceived',
|
||||
)
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
|
||||
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
|
||||
assert sources[0].event_name == 'PersonNormalMessageReceived'
|
||||
assert sources[0].is_approximate is False
|
||||
assert '_plugin_response_sources' not in query.variables
|
||||
assert '_plugin_pending_response_sources' not in query.variables
|
||||
|
||||
|
||||
class TestResponseWrapperCommand:
|
||||
"""Tests for command response wrapping."""
|
||||
@@ -125,7 +167,7 @@ class TestResponseWrapperCommand:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -133,7 +175,7 @@ class TestResponseWrapperCommand:
|
||||
command_resp = Mock()
|
||||
command_resp.role = 'command'
|
||||
command_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Help info")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Help info')])
|
||||
)
|
||||
query.resp_messages = [command_resp]
|
||||
|
||||
@@ -163,7 +205,7 @@ class TestResponseWrapperPlugin:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -171,7 +213,7 @@ class TestResponseWrapperPlugin:
|
||||
plugin_resp = Mock()
|
||||
plugin_resp.role = 'plugin'
|
||||
plugin_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Plugin response")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Plugin response')])
|
||||
)
|
||||
query.resp_messages = [plugin_resp]
|
||||
|
||||
@@ -211,17 +253,17 @@ class TestResponseWrapperAssistant:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with content
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello back!"
|
||||
assistant_resp.content = 'Hello back!'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello back!")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Hello back!')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
@@ -247,7 +289,7 @@ class TestResponseWrapperAssistant:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -292,7 +334,7 @@ class TestResponseWrapperAssistant:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
@@ -303,10 +345,10 @@ class TestResponseWrapperAssistant:
|
||||
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Processing..."
|
||||
assistant_resp.content = 'Processing...'
|
||||
assistant_resp.tool_calls = [mock_tool_call]
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Processing...")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
@@ -346,17 +388,17 @@ class TestResponseWrapperInterrupt:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with content
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello!"
|
||||
assistant_resp.content = 'Hello!'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello!")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Hello!')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
@@ -384,7 +426,7 @@ class TestResponseWrapperCustomReply:
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector with custom reply
|
||||
custom_chain = platform_message.MessageChain([platform_message.Plain(text="Custom reply")])
|
||||
custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')])
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
@@ -397,17 +439,17 @@ class TestResponseWrapperCustomReply:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Default reply"
|
||||
assistant_resp.content = 'Default reply'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Default reply")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
@@ -421,7 +463,105 @@ class TestResponseWrapperCustomReply:
|
||||
assert len(results[0].new_query.resp_message_chain) == 1
|
||||
# Should be the custom chain
|
||||
chain = results[0].new_query.resp_message_chain[0]
|
||||
assert "Custom reply" in str(chain)
|
||||
assert 'Custom reply' in str(chain)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reply_records_plugin_source(self):
|
||||
"""Plugin reply_message_chain should keep emitted plugin attribution."""
|
||||
wrapper = get_wrapper_module()
|
||||
|
||||
app = FakeApp()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
|
||||
|
||||
custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')])
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = custom_chain
|
||||
mock_event_ctx._emitted_plugins = [
|
||||
{
|
||||
'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}},
|
||||
'plugin_config': {'token': 'secret-token'},
|
||||
},
|
||||
]
|
||||
mock_event_ctx._response_sources = [
|
||||
{
|
||||
'kind': 'reply_message_chain',
|
||||
'plugin': {'author': 'tester', 'name': 'demo'},
|
||||
}
|
||||
]
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
pipeline_config = make_wrapper_config()
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = 'Default reply'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
plugin_diagnostics = get_plugin_diagnostics_module()
|
||||
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
|
||||
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
|
||||
assert sources[0].event_name == 'NormalMessageResponded'
|
||||
assert sources[0].is_approximate is False
|
||||
assert 'secret-token' not in str(sources)
|
||||
assert '_plugin_response_sources' not in query.variables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reply_falls_back_to_emitted_plugins_for_old_runtime(self):
|
||||
"""Older plugin runtimes without response_sources keep approximate attribution."""
|
||||
wrapper = get_wrapper_module()
|
||||
|
||||
app = FakeApp()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
|
||||
|
||||
custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')])
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = custom_chain
|
||||
mock_event_ctx._emitted_plugins = [
|
||||
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
|
||||
]
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
pipeline_config = make_wrapper_config()
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = 'Default reply'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
plugin_diagnostics = get_plugin_diagnostics_module()
|
||||
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
|
||||
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
|
||||
assert sources[0].is_approximate is True
|
||||
|
||||
|
||||
class TestResponseWrapperVariables:
|
||||
@@ -452,7 +592,7 @@ class TestResponseWrapperVariables:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
query.variables['_pipeline_bound_plugins'] = ['plugin1', 'plugin2']
|
||||
@@ -460,10 +600,10 @@ class TestResponseWrapperVariables:
|
||||
# Create assistant response
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello"
|
||||
assistant_resp.content = 'Hello'
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello")])
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text='Hello')])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for ResponseWrapper outbound-attachment helpers.
|
||||
|
||||
Covers the sandbox -> user attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant
|
||||
message (or a final MessageChunk) should trigger collection.
|
||||
* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once
|
||||
per query and maps each descriptor to the right platform component, swallowing
|
||||
collection errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper
|
||||
|
||||
|
||||
def _make_wrapper(box_service) -> ResponseWrapper:
|
||||
app = SimpleNamespace(logger=Mock())
|
||||
wrapper = ResponseWrapper.__new__(ResponseWrapper)
|
||||
wrapper.ap = app
|
||||
return wrapper
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={})
|
||||
|
||||
|
||||
def test_is_final_assistant_message_plain_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='assistant', content='done')
|
||||
assert wrapper._is_final_assistant_message(msg) is True
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_non_assistant():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(role='tool', content='{}')
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_rejects_tool_call_round():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content='calling',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='c1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='{}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
assert wrapper._is_final_assistant_message(msg) is False
|
||||
|
||||
|
||||
def test_is_final_assistant_message_non_final_chunk():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False)
|
||||
assert wrapper._is_final_assistant_message(chunk) is False
|
||||
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True)
|
||||
assert wrapper._is_final_assistant_message(final_chunk) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_maps_each_type():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(
|
||||
return_value=[
|
||||
{'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'},
|
||||
{'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='},
|
||||
{'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='},
|
||||
]
|
||||
),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
kinds = [type(c).__name__ for c in chain]
|
||||
assert kinds == ['Image', 'Voice', 'File']
|
||||
assert query.variables['_sandbox_outbound_collected'] is True
|
||||
# File keeps its name
|
||||
file_comp = chain[2]
|
||||
assert getattr(file_comp, 'name', None) == 'report.xlsx'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_runs_once_per_query():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(return_value=[]),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
|
||||
box_service.collect_outbound_attachments.assert_not_awaited()
|
||||
assert len(chain) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_noop_without_box_service():
|
||||
wrapper = _make_wrapper(box_service=None)
|
||||
wrapper.ap.box_service = None
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
# not marked collected, since service is unavailable
|
||||
assert '_sandbox_outbound_collected' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_outbound_attachments_swallows_collection_error():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')),
|
||||
)
|
||||
wrapper = _make_wrapper(box_service)
|
||||
wrapper.ap.box_service = box_service
|
||||
query = _make_query()
|
||||
chain = platform_message.MessageChain([])
|
||||
|
||||
# must not raise
|
||||
await wrapper._append_outbound_attachments(query, chain)
|
||||
assert len(chain) == 0
|
||||
wrapper.ap.logger.warning.assert_called_once()
|
||||
@@ -0,0 +1,538 @@
|
||||
import pytest
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.aiocqhttp import (
|
||||
AiocqhttpAdapter,
|
||||
AiocqhttpEventConverter,
|
||||
AiocqhttpMessageConverter,
|
||||
)
|
||||
|
||||
|
||||
async def _convert_single(component: platform_message.MessageComponent):
|
||||
chain = platform_message.MessageChain([component])
|
||||
message, _, _ = await AiocqhttpMessageConverter.yiri2target(chain)
|
||||
return message[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('payload', 'expected'),
|
||||
[
|
||||
('data:image/jpeg;base64,raw-image', 'base64://raw-image'),
|
||||
('raw-image', 'base64://raw-image'),
|
||||
('base64://raw-image', 'base64://raw-image'),
|
||||
],
|
||||
)
|
||||
async def test_image_base64_payload_is_normalized(payload, expected):
|
||||
segment = await _convert_single(platform_message.Image(base64=payload))
|
||||
|
||||
assert segment.type == 'image'
|
||||
assert segment.data['file'] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_data_uri_base64_payload_is_normalized():
|
||||
segment = await _convert_single(platform_message.Voice(base64='data:audio/wav;base64,raw-voice'))
|
||||
|
||||
assert segment.type == 'record'
|
||||
assert segment.data['file'] == 'base64://raw-voice'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('component', 'expected'),
|
||||
[
|
||||
(
|
||||
platform_message.File(name='report.txt', base64='data:text/plain;base64,raw-file'),
|
||||
{'file': 'base64://raw-file', 'name': 'report.txt'},
|
||||
),
|
||||
(
|
||||
platform_message.File(name='report.txt', base64='raw-file'),
|
||||
{'file': 'base64://raw-file', 'name': 'report.txt'},
|
||||
),
|
||||
(
|
||||
platform_message.File(name='a.txt', url='http://example.com/a.txt'),
|
||||
{'file': 'http://example.com/a.txt', 'name': 'a.txt'},
|
||||
),
|
||||
(
|
||||
platform_message.File(name='a.txt', path='/tmp/a.txt'),
|
||||
{'file': '/tmp/a.txt', 'name': 'a.txt'},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_file_message_uses_available_file_source(component, expected):
|
||||
segment = await _convert_single(component)
|
||||
|
||||
assert segment.type == 'file'
|
||||
assert segment.data == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_image_base64_payload_is_normalized():
|
||||
forward = platform_message.Forward(
|
||||
node_list=[
|
||||
platform_message.ForwardMessageNode(
|
||||
sender_id='10001',
|
||||
sender_name='Tester',
|
||||
message_chain=platform_message.MessageChain(
|
||||
[platform_message.Image(base64='data:image/png;base64,raw-forward-image')]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
messages = []
|
||||
|
||||
class Logger:
|
||||
async def info(self, _message):
|
||||
return None
|
||||
|
||||
async def error(self, _message):
|
||||
return None
|
||||
|
||||
class Bot:
|
||||
async def call_action(self, action, **kwargs):
|
||||
assert action == 'send_forward_msg'
|
||||
messages.append(kwargs)
|
||||
|
||||
platform = AiocqhttpAdapter.model_construct(
|
||||
bot_account_id='10000',
|
||||
config={},
|
||||
logger=Logger(),
|
||||
bot=Bot(),
|
||||
)
|
||||
|
||||
await platform._send_forward_message(1000, forward)
|
||||
|
||||
assert messages[0]['messages'][0]['data']['content'][0] == {
|
||||
'type': 'image',
|
||||
'data': {'file': 'base64://raw-forward-image'},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_member_name_prefers_group_card():
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': 'Special Title',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
class Bot:
|
||||
async def get_group_info(self, group_id):
|
||||
assert group_id == 2000
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
||||
|
||||
assert converted.sender.member_name == 'Group Card'
|
||||
assert converted.sender.group.id == 2000
|
||||
assert converted.sender.group.name == 'Test Group'
|
||||
assert converted.sender.special_title == 'Special Title'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_member_name_falls_back_to_nickname():
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': '',
|
||||
'role': 'member',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
converted = await AiocqhttpEventConverter().target2yiri(event)
|
||||
|
||||
assert converted.sender.member_name == 'QQ Nickname'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty():
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': '',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
class Bot:
|
||||
async def get_group_info(self, group_id):
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
async def get_group_member_info(self, group_id, user_id):
|
||||
assert group_id == 2000
|
||||
assert user_id == 3000
|
||||
return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'}
|
||||
|
||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
||||
|
||||
assert converted.sender.special_title == 'Member Title'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_special_title_does_not_lookup_when_sender_title_exists():
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': 'Event Title',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
class Bot:
|
||||
async def get_group_info(self, group_id):
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
async def get_group_member_info(self, group_id, user_id):
|
||||
raise AssertionError('get_group_member_info should not be called')
|
||||
|
||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
||||
|
||||
assert converted.sender.special_title == 'Event Title'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': '',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
member_info_calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
async def get_group_member_info(self, group_id, user_id):
|
||||
self.member_info_calls += 1
|
||||
raise RuntimeError('api unavailable')
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
first = await converter.target2yiri(event, bot)
|
||||
second = await converter.target2yiri(event, bot)
|
||||
|
||||
assert first.sender.special_title == ''
|
||||
assert second.sender.special_title == ''
|
||||
assert bot.member_info_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_special_title_member_info_cache_expires(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': '',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
member_info_calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
async def get_group_member_info(self, group_id, user_id):
|
||||
self.member_info_calls += 1
|
||||
return {
|
||||
'group_id': group_id,
|
||||
'user_id': user_id,
|
||||
'title': f'Member Title {self.member_info_calls}',
|
||||
}
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
first = await converter.target2yiri(event, bot)
|
||||
now = 87401.0
|
||||
second = await converter.target2yiri(event, bot)
|
||||
|
||||
assert first.sender.special_title == 'Member Title 1'
|
||||
assert second.sender.special_title == 'Member Title 2'
|
||||
assert bot.member_info_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
'title': '',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
member_info_calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
||||
|
||||
async def get_group_member_info(self, group_id, user_id):
|
||||
self.member_info_calls += 1
|
||||
if self.member_info_calls == 1:
|
||||
raise RuntimeError('api unavailable')
|
||||
return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'}
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
failed = await converter.target2yiri(event, bot)
|
||||
now = 1601.0
|
||||
recovered = await converter.target2yiri(event, bot)
|
||||
|
||||
assert failed.sender.special_title == ''
|
||||
assert recovered.sender.special_title == 'Recovered Title'
|
||||
assert bot.member_info_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_group_name_is_cached(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
class Bot:
|
||||
calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
self.calls += 1
|
||||
assert group_id == 2000
|
||||
return {'group_id': group_id, 'group_name': 'Cached Group'}
|
||||
|
||||
monotonic = 1000.0
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
first = await converter.target2yiri(event, bot)
|
||||
second = await converter.target2yiri(event, bot)
|
||||
|
||||
assert first.sender.group.name == 'Cached Group'
|
||||
assert second.sender.group.name == 'Cached Group'
|
||||
assert bot.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_group_name_cache_expires(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
self.calls += 1
|
||||
return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'}
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
first = await converter.target2yiri(event, bot)
|
||||
now = 4601.0
|
||||
second = await converter.target2yiri(event, bot)
|
||||
|
||||
assert first.sender.group.name == 'Group Name 1'
|
||||
assert second.sender.group.name == 'Group Name 2'
|
||||
assert bot.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
self.calls += 1
|
||||
raise RuntimeError('api unavailable')
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
converted = await converter.target2yiri(event, bot)
|
||||
cached_failure = await converter.target2yiri(event, bot)
|
||||
|
||||
assert converted.sender.group.name == 'Group 2000'
|
||||
assert cached_failure.sender.group.name == 'Group 2000'
|
||||
assert bot.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch):
|
||||
event = aiocqhttp.Event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'message_id': 1000,
|
||||
'message': '',
|
||||
'time': 1776491725,
|
||||
'group_id': 2000,
|
||||
'sender': {
|
||||
'user_id': 3000,
|
||||
'nickname': 'QQ Nickname',
|
||||
'card': 'Group Card',
|
||||
'role': 'member',
|
||||
},
|
||||
}
|
||||
)
|
||||
now = 1000.0
|
||||
|
||||
class Bot:
|
||||
calls = 0
|
||||
|
||||
async def get_group_info(self, group_id):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError('api unavailable')
|
||||
return {'group_id': group_id, 'group_name': 'Recovered Group'}
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
||||
|
||||
bot = Bot()
|
||||
converter = AiocqhttpEventConverter()
|
||||
|
||||
failed = await converter.target2yiri(event, bot)
|
||||
now = 1061.0
|
||||
recovered = await converter.target2yiri(event, bot)
|
||||
|
||||
assert failed.sender.group.name == 'Group 2000'
|
||||
assert recovered.sender.group.name == 'Recovered Group'
|
||||
assert bot.calls == 2
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for WebSocketAdapter._process_image_components.
|
||||
|
||||
The web debug client uploads Image / Voice / File components carrying a storage
|
||||
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
|
||||
LLM input and the Box sandbox inbox have usable bytes), then deletes the
|
||||
consumed storage object and clears ``path``. Covers mimetype selection per
|
||||
type and graceful error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter
|
||||
|
||||
|
||||
def _make_adapter(load_return=b'hello', load_side_effect=None):
|
||||
provider = Mock()
|
||||
provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect)
|
||||
provider.delete = AsyncMock()
|
||||
ap = Mock()
|
||||
ap.storage_mgr.storage_provider = provider
|
||||
logger = Mock()
|
||||
logger.error = AsyncMock()
|
||||
# WebSocketAdapter is a pydantic model; bypass full __init__/validation.
|
||||
adapter = WebSocketAdapter.model_construct(ap=ap, logger=logger)
|
||||
return adapter, provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_jpeg_mimetype_and_cleanup():
|
||||
adapter, provider = _make_adapter(load_return=b'\xff\xd8\xff')
|
||||
chain = [{'type': 'Image', 'path': 'storage://abc/photo.jpg'}]
|
||||
|
||||
await adapter._process_image_components(chain)
|
||||
|
||||
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
|
||||
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
||||
assert chain[0]['path'] == '' # consumed
|
||||
provider.delete.assert_awaited_once_with('storage://abc/photo.jpg')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_defaults_to_png():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'Image', 'path': 'storage://abc/blob'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:image/png;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_uses_guessed_or_wav_mimetype():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'Voice', 'path': 'storage://abc/clip.wav'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:audio/')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uses_octet_stream_fallback():
|
||||
adapter, _ = _make_adapter()
|
||||
chain = [{'type': 'File', 'path': 'storage://abc/unknownblob'}]
|
||||
await adapter._process_image_components(chain)
|
||||
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_components_without_path_or_unknown_type():
|
||||
adapter, provider = _make_adapter()
|
||||
chain = [
|
||||
{'type': 'Image', 'path': ''}, # no path
|
||||
{'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component
|
||||
{'type': 'At', 'target': '123'}, # no path key at all
|
||||
]
|
||||
await adapter._process_image_components(chain)
|
||||
provider.load.assert_not_awaited()
|
||||
assert 'base64' not in chain[0]
|
||||
assert 'base64' not in chain[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_failure_is_logged_not_raised():
|
||||
adapter, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
|
||||
chain = [{'type': 'File', 'path': 'storage://abc/doc.pdf'}]
|
||||
|
||||
# must not raise
|
||||
await adapter._process_image_components(chain)
|
||||
assert 'base64' not in chain[0]
|
||||
adapter.logger.error.assert_awaited_once()
|
||||
@@ -0,0 +1,91 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter
|
||||
|
||||
|
||||
class DummyLogger(abstract_platform_logger.AbstractEventLogger):
|
||||
async def info(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def debug(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def warning(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def error(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def make_adapter():
|
||||
return WecomCSAdapter(
|
||||
config={
|
||||
'corpid': 'corp-id',
|
||||
'secret': 'secret',
|
||||
'token': 'token',
|
||||
'EncodingAESKey': 'encoding-key',
|
||||
},
|
||||
logger=DummyLogger(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sends_text_to_customer_service_user():
|
||||
adapter = make_adapter()
|
||||
adapter.bot_account_id = 'kf-test'
|
||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
||||
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
await adapter.send_message('person', 'uexternal-user', message)
|
||||
|
||||
adapter.bot.send_text_msg.assert_awaited_once()
|
||||
kwargs = adapter.bot.send_text_msg.await_args.kwargs
|
||||
assert kwargs['open_kfid'] == 'kf-test'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
assert kwargs['content'] == 'hello'
|
||||
assert kwargs['msgid'].startswith('langbot_')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_allows_explicit_open_kfid_in_target_id():
|
||||
adapter = make_adapter()
|
||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
||||
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
await adapter.send_message('person', 'kf-explicit|uexternal-user', message)
|
||||
|
||||
kwargs = adapter.bot.send_text_msg.await_args.kwargs
|
||||
assert kwargs['open_kfid'] == 'kf-explicit'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_requires_open_kfid():
|
||||
adapter = make_adapter()
|
||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
with pytest.raises(ValueError, match='open_kfid is required'):
|
||||
await adapter.send_message('person', 'uexternal-user', message)
|
||||
|
||||
adapter.bot.send_text_msg.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_rejects_group_targets():
|
||||
adapter = make_adapter()
|
||||
adapter.bot_account_id = 'kf-test'
|
||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
with pytest.raises(ValueError, match='only supports sending messages to person'):
|
||||
await adapter.send_message('group', 'group-id', message)
|
||||
|
||||
adapter.bot.send_text_msg.assert_not_called()
|
||||
@@ -6,12 +6,15 @@ Tests cover:
|
||||
- RAG methods (ingest, retrieve, schema)
|
||||
- Disabled plugin early returns
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import text_query
|
||||
|
||||
|
||||
def get_connector_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
@@ -86,16 +89,12 @@ class TestListPlugins:
|
||||
return_value=[
|
||||
{
|
||||
'manifest': {'manifest': {'metadata': {'author': 'a', 'name': 'p1'}}},
|
||||
'components': [
|
||||
{'manifest': {'manifest': {'kind': 'Command'}}}
|
||||
],
|
||||
'components': [{'manifest': {'manifest': {'kind': 'Command'}}}],
|
||||
'debug': False,
|
||||
},
|
||||
{
|
||||
'manifest': {'manifest': {'metadata': {'author': 'b', 'name': 'p2'}}},
|
||||
'components': [
|
||||
{'manifest': {'manifest': {'kind': 'Tool'}}}
|
||||
],
|
||||
'components': [{'manifest': {'manifest': {'kind': 'Tool'}}}],
|
||||
'debug': False,
|
||||
},
|
||||
]
|
||||
@@ -127,9 +126,7 @@ class TestListPlugins:
|
||||
},
|
||||
]
|
||||
)
|
||||
connector.ap.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(__iter__=lambda self: iter([]))
|
||||
)
|
||||
connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(__iter__=lambda self: iter([])))
|
||||
|
||||
result = await connector.list_plugins()
|
||||
|
||||
@@ -137,6 +134,130 @@ class TestListPlugins:
|
||||
assert result[0]['debug'] is True
|
||||
|
||||
|
||||
class TestPluginDiagnostics:
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_event_preserves_response_sources(self):
|
||||
connector = create_mock_connector()
|
||||
query = text_query('hello')
|
||||
event = query.message_event
|
||||
object.__setattr__(event, 'query', query)
|
||||
connector_module = get_connector_module()
|
||||
original_from_event = connector_module.context.EventContext.from_event
|
||||
original_model_validate = connector_module.context.EventContext.model_validate
|
||||
response_sources = [
|
||||
{
|
||||
'kind': 'reply_message_chain',
|
||||
'plugin': {'author': 'tester', 'name': 'demo'},
|
||||
}
|
||||
]
|
||||
|
||||
async def emit_event_response(event_context, include_plugins=None):
|
||||
return {
|
||||
'event_context': event_context,
|
||||
'emitted_plugins': [],
|
||||
'response_sources': response_sources,
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
event_dump = event.model_dump()
|
||||
event_dump['event_name'] = 'FriendMessage'
|
||||
fake_event_ctx.model_dump.return_value = {
|
||||
'query_id': query.query_id,
|
||||
'eid': 0,
|
||||
'event_name': 'FriendMessage',
|
||||
'event': event_dump,
|
||||
'is_prevent_default': False,
|
||||
'is_prevent_postorder': False,
|
||||
}
|
||||
connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx)
|
||||
parsed_event_ctx = Mock()
|
||||
connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx)
|
||||
try:
|
||||
event_ctx = await connector.emit_event(event)
|
||||
finally:
|
||||
connector_module.context.EventContext.from_event = original_from_event
|
||||
connector_module.context.EventContext.model_validate = original_model_validate
|
||||
|
||||
assert event_ctx is parsed_event_ctx
|
||||
assert event_ctx._response_sources == response_sources
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_event_leaves_response_sources_absent_for_old_runtime(self):
|
||||
connector = create_mock_connector()
|
||||
query = text_query('hello')
|
||||
event = query.message_event
|
||||
object.__setattr__(event, 'query', query)
|
||||
connector_module = get_connector_module()
|
||||
original_from_event = connector_module.context.EventContext.from_event
|
||||
original_model_validate = connector_module.context.EventContext.model_validate
|
||||
|
||||
async def emit_event_response(event_context, include_plugins=None):
|
||||
return {
|
||||
'event_context': event_context,
|
||||
'emitted_plugins': [
|
||||
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
|
||||
],
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
event_dump = event.model_dump()
|
||||
event_dump['event_name'] = 'FriendMessage'
|
||||
fake_event_ctx.model_dump.return_value = {
|
||||
'query_id': query.query_id,
|
||||
'eid': 0,
|
||||
'event_name': 'FriendMessage',
|
||||
'event': event_dump,
|
||||
'is_prevent_default': False,
|
||||
'is_prevent_postorder': False,
|
||||
}
|
||||
connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx)
|
||||
parsed_event_ctx = Mock()
|
||||
connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx)
|
||||
try:
|
||||
event_ctx = await connector.emit_event(event)
|
||||
finally:
|
||||
connector_module.context.EventContext.from_event = original_from_event
|
||||
connector_module.context.EventContext.model_validate = original_model_validate
|
||||
|
||||
assert '_response_sources' not in vars(event_ctx)
|
||||
assert event_ctx._emitted_plugins == [
|
||||
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_plugin_diagnostic_skips_when_disabled(self):
|
||||
connector_module = get_connector_module()
|
||||
|
||||
async def mock_disconnect(conn):
|
||||
pass
|
||||
|
||||
mock_app = create_mock_app()
|
||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||
connector.handler = AsyncMock()
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
|
||||
connector.handler.notify_plugin_diagnostic.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_plugin_diagnostic_is_best_effort(self):
|
||||
connector = create_mock_connector()
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
|
||||
connector.handler.notify_plugin_diagnostic.assert_awaited_once()
|
||||
connector.ap.logger.debug.assert_called_once()
|
||||
|
||||
|
||||
class TestListKnowledgeEngines:
|
||||
"""Tests for list_knowledge_engines method."""
|
||||
|
||||
@@ -230,7 +351,8 @@ class TestCallParser:
|
||||
)
|
||||
|
||||
connector.handler.parse_document.assert_called_once_with(
|
||||
'author', 'parser',
|
||||
'author',
|
||||
'parser',
|
||||
{'mime_type': 'text/plain', 'filename': 'test.txt'},
|
||||
b'file content',
|
||||
)
|
||||
@@ -251,9 +373,7 @@ class TestRAGMethods:
|
||||
|
||||
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
|
||||
|
||||
connector.handler.rag_ingest_document.assert_called_once_with(
|
||||
'author', 'engine', {'file': 'test.pdf'}
|
||||
)
|
||||
connector.handler.rag_ingest_document.assert_called_once_with('author', 'engine', {'file': 'test.pdf'})
|
||||
assert result['status'] == 'success'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -264,14 +384,16 @@ class TestRAGMethods:
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.retrieve_knowledge = AsyncMock(
|
||||
return_value={'results': [{'id': 'doc1', 'content': [{'type': 'text', 'text': 'test'}], 'metadata': {}, 'distance': 0.1}]}
|
||||
return_value={
|
||||
'results': [
|
||||
{'id': 'doc1', 'content': [{'type': 'text', 'text': 'test'}], 'metadata': {}, 'distance': 0.1}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await connector.call_rag_retrieve('author/engine', {'query': 'test'})
|
||||
|
||||
connector.handler.retrieve_knowledge.assert_called_once_with(
|
||||
'author', 'engine', '', {'query': 'test'}
|
||||
)
|
||||
connector.handler.retrieve_knowledge.assert_called_once_with('author', 'engine', '', {'query': 'test'})
|
||||
assert result == {
|
||||
'results': [
|
||||
{
|
||||
@@ -290,9 +412,7 @@ class TestRAGMethods:
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.get_rag_creation_schema = AsyncMock(
|
||||
return_value={'properties': {'name': {'type': 'string'}}}
|
||||
)
|
||||
connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}})
|
||||
|
||||
result = await connector.get_rag_creation_schema('author/engine')
|
||||
|
||||
@@ -326,9 +446,7 @@ class TestRAGMethods:
|
||||
|
||||
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
|
||||
|
||||
connector.handler.rag_on_kb_create.assert_called_once_with(
|
||||
'author', 'engine', 'kb-uuid', {'model': 'test'}
|
||||
)
|
||||
connector.handler.rag_on_kb_create.assert_called_once_with('author', 'engine', 'kb-uuid', {'model': 'test'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_on_kb_delete(self):
|
||||
@@ -354,9 +472,7 @@ class TestRAGMethods:
|
||||
|
||||
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
|
||||
|
||||
connector.handler.rag_delete_document.assert_called_once_with(
|
||||
'author', 'engine', 'doc-uuid', 'kb-uuid'
|
||||
)
|
||||
connector.handler.rag_delete_document.assert_called_once_with('author', 'engine', 'doc-uuid', 'kb-uuid')
|
||||
assert result is True
|
||||
|
||||
|
||||
@@ -446,9 +562,7 @@ class TestGetPluginInfo:
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.get_plugin_info = AsyncMock(
|
||||
return_value={'manifest': {'metadata': {'name': 'plugin'}}}
|
||||
)
|
||||
connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}})
|
||||
|
||||
result = await connector.get_plugin_info('author', 'plugin')
|
||||
|
||||
@@ -470,9 +584,7 @@ class TestSetPluginConfig:
|
||||
|
||||
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
|
||||
|
||||
connector.handler.set_plugin_config.assert_called_once_with(
|
||||
'author', 'plugin', {'setting': 'value'}
|
||||
)
|
||||
connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'})
|
||||
|
||||
|
||||
class TestPingPluginRuntime:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Tests cover:
|
||||
- _parse_plugin_id() parsing and validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -6,6 +6,7 @@ Tests cover:
|
||||
- Handling missing requirements.txt
|
||||
- Handling empty/malformed requirements.txt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
@@ -82,13 +83,13 @@ class TestExtractDepsMetadata:
|
||||
"""Test that comments and empty lines are filtered."""
|
||||
connector_instance = create_mock_connector()
|
||||
|
||||
requirements = '''# This is a comment
|
||||
requirements = """# This is a comment
|
||||
requests>=2.0
|
||||
|
||||
# Another comment
|
||||
flask==1.0
|
||||
|
||||
numpy'''
|
||||
numpy"""
|
||||
zip_bytes = create_zip_with_requirements(requirements)
|
||||
|
||||
task_context = Mock()
|
||||
@@ -147,9 +148,9 @@ numpy'''
|
||||
"""Test handling requirements.txt with only comments."""
|
||||
connector_instance = create_mock_connector()
|
||||
|
||||
requirements = '''# Comment 1
|
||||
requirements = """# Comment 1
|
||||
# Comment 2
|
||||
# Comment 3'''
|
||||
# Comment 3"""
|
||||
zip_bytes = create_zip_with_requirements(requirements)
|
||||
|
||||
task_context = Mock()
|
||||
|
||||
@@ -40,11 +40,13 @@ class TestHandlerQueryVariables:
|
||||
"""Test set_query_var returns error when query not found."""
|
||||
runtime_handler = make_handler(mock_app)
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({
|
||||
'query_id': 'nonexistent-query',
|
||||
'key': 'test_var',
|
||||
'value': 'test_value',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
|
||||
{
|
||||
'query_id': 'nonexistent-query',
|
||||
'key': 'test_var',
|
||||
'value': 'test_value',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
assert 'nonexistent-query' in response.message
|
||||
@@ -58,11 +60,13 @@ class TestHandlerQueryVariables:
|
||||
|
||||
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': 'test_var',
|
||||
'value': 'test_value',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value](
|
||||
{
|
||||
'query_id': 'test-query',
|
||||
'key': 'test_var',
|
||||
'value': 'test_value',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert mock_query.variables['test_var'] == 'test_value'
|
||||
@@ -76,10 +80,12 @@ class TestHandlerQueryVariables:
|
||||
|
||||
mock_app.query_pool.cached_queries['test-query'] = mock_query
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({
|
||||
'query_id': 'test-query',
|
||||
'key': 'existing_var',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value](
|
||||
{
|
||||
'query_id': 'test-query',
|
||||
'key': 'existing_var',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {'value': 'existing_value'}
|
||||
@@ -93,9 +99,11 @@ class TestHandlerQueryVariables:
|
||||
|
||||
mock_app.query_pool.cached_queries['test-query'] = mock_query
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({
|
||||
'query_id': 'test-query',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value](
|
||||
{
|
||||
'query_id': 'test-query',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {'vars': mock_query.variables}
|
||||
@@ -108,7 +116,7 @@ class TestHandlerRagErrorResponse:
|
||||
"""Test basic error response creation."""
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = Exception("test error")
|
||||
error = Exception('test error')
|
||||
response = _make_rag_error_response(error, 'TestError')
|
||||
|
||||
# ActionResponse is a pydantic model, check message field
|
||||
@@ -120,13 +128,8 @@ class TestHandlerRagErrorResponse:
|
||||
"""Test error response with extra context."""
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = ValueError("invalid input")
|
||||
response = _make_rag_error_response(
|
||||
error,
|
||||
'ValidationError',
|
||||
field='name',
|
||||
value='test'
|
||||
)
|
||||
error = ValueError('invalid input')
|
||||
response = _make_rag_error_response(error, 'ValidationError', field='name', value='test')
|
||||
|
||||
assert 'ValidationError' in response.message
|
||||
assert 'field=name' in response.message
|
||||
@@ -137,7 +140,7 @@ class TestHandlerRagErrorResponse:
|
||||
"""Test error response includes exception type."""
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = RuntimeError("connection failed")
|
||||
error = RuntimeError('connection failed')
|
||||
response = _make_rag_error_response(error, 'ConnectionError')
|
||||
|
||||
assert 'RuntimeError' in response.message
|
||||
@@ -148,7 +151,7 @@ class TestHandlerRagErrorResponse:
|
||||
"""Test error response with no extra context."""
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = KeyError("missing_key")
|
||||
error = KeyError('missing_key')
|
||||
response = _make_rag_error_response(error, 'LookupError')
|
||||
|
||||
# No context parts means no brackets
|
||||
@@ -156,6 +159,36 @@ class TestHandlerRagErrorResponse:
|
||||
assert 'KeyError' in response.message
|
||||
|
||||
|
||||
class TestHandlerPluginDiagnostic:
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_plugin_diagnostic_falls_back_to_raw_protocol_action(self):
|
||||
"""Diagnostic forwarding works before the SDK enum exists."""
|
||||
app = SimpleNamespace()
|
||||
app.logger = SimpleNamespace(debug=MagicMock())
|
||||
runtime_handler = make_handler(app)
|
||||
runtime_handler.call_action = AsyncMock(return_value={})
|
||||
|
||||
payload = {'code': 'response_delivery_failed'}
|
||||
await runtime_handler.notify_plugin_diagnostic(payload)
|
||||
|
||||
action = runtime_handler.call_action.await_args.args[0]
|
||||
assert action.value == 'plugin_diagnostic'
|
||||
assert runtime_handler.call_action.await_args.args[1] is payload
|
||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 5
|
||||
|
||||
def test_langbot_to_runtime_action_uses_enum_when_available(self):
|
||||
"""The compatibility helper should prefer SDK enums once available."""
|
||||
from langbot.pkg.plugin import handler as plugin_handler
|
||||
|
||||
sentinel = object()
|
||||
original = plugin_handler.LangBotToRuntimeAction
|
||||
plugin_handler.LangBotToRuntimeAction = SimpleNamespace(PLUGIN_DIAGNOSTIC=sentinel)
|
||||
try:
|
||||
assert plugin_handler._langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic') is sentinel
|
||||
finally:
|
||||
plugin_handler.LangBotToRuntimeAction = original
|
||||
|
||||
|
||||
class TestConstantsSemanticVersion:
|
||||
"""Tests for version constant access."""
|
||||
|
||||
|
||||
@@ -27,6 +27,68 @@ def compiled_params(statement):
|
||||
return statement.compile().params
|
||||
|
||||
|
||||
class TestRagRerankAction:
|
||||
"""Tests for RAG rerank action handler."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.model_mgr = Mock()
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invokes_rerank_model_and_sorts_scores(self, app):
|
||||
"""Rerank action uses the selected model and returns top scores."""
|
||||
provider = Mock()
|
||||
provider.invoke_rerank = AsyncMock(
|
||||
return_value=[
|
||||
{'index': 0, 'relevance_score': 0.2},
|
||||
{'index': 1, 'relevance_score': 0.9},
|
||||
]
|
||||
)
|
||||
rerank_model = SimpleNamespace(provider=provider)
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=rerank_model)
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
|
||||
{
|
||||
'rerank_model_uuid': 'rerank-1',
|
||||
'query': 'hello',
|
||||
'documents': ['a', 'b'],
|
||||
'top_k': 1,
|
||||
'extra_args': {'return_documents': False},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}]
|
||||
app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with('rerank-1')
|
||||
provider.invoke_rerank.assert_awaited_once_with(
|
||||
model=rerank_model,
|
||||
query='hello',
|
||||
documents=['a', 'b'],
|
||||
extra_args={'return_documents': False},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_rerank_model_missing(self, app):
|
||||
"""Missing rerank model returns an action error."""
|
||||
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=ValueError('not found'))
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
|
||||
{
|
||||
'rerank_model_uuid': 'missing',
|
||||
'query': 'hello',
|
||||
'documents': ['a'],
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
assert 'Rerank model with rerank_model_uuid missing not found' in response.message
|
||||
|
||||
|
||||
class TestInitializePluginSettings:
|
||||
"""Tests for initialize_plugin_settings action handler."""
|
||||
|
||||
@@ -47,12 +109,14 @@ class TestInitializePluginSettings:
|
||||
Mock(),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
@@ -82,12 +146,14 @@ class TestInitializePluginSettings:
|
||||
Mock(),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'author/name'},
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'author/name'},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
@@ -161,9 +227,7 @@ class TestSetBinaryStorage:
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old'))
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'new')
|
||||
)
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
@@ -203,9 +267,7 @@ class TestSetBinaryStorage:
|
||||
runtime_handler = make_handler(app)
|
||||
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x')
|
||||
)
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'x'))
|
||||
|
||||
assert response.code != 0
|
||||
assert '1 > 0 bytes' in response.message
|
||||
@@ -228,10 +290,12 @@ class TestGetPluginSettings:
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
@@ -255,10 +319,12 @@ class TestGetPluginSettings:
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(setting)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
@@ -286,11 +352,13 @@ class TestGetBinaryStorage:
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content'))
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
@@ -303,11 +371,13 @@ class TestGetBinaryStorage:
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
})
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
assert 'Storage with key test-key not found' in response.message
|
||||
@@ -329,9 +399,11 @@ class TestHandlerQueryLookup:
|
||||
"""Query-bound actions return error when query_id is not cached."""
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
|
||||
'query_id': 'nonexistent-query',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value](
|
||||
{
|
||||
'query_id': 'nonexistent-query',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
assert 'nonexistent-query' in response.message
|
||||
@@ -343,9 +415,11 @@ class TestHandlerQueryLookup:
|
||||
query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid')
|
||||
app.query_pool.cached_queries['existing-query'] = query
|
||||
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
|
||||
'query_id': 'existing-query',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value](
|
||||
{
|
||||
'query_id': 'existing-query',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {'bot_uuid': 'test-bot-uuid'}
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests cover:
|
||||
- _make_rag_error_response() helper function
|
||||
- RuntimeConnectionHandler cleanup_plugin_data method
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -23,7 +24,7 @@ class TestMakeRagErrorResponse:
|
||||
"""Test basic error response creation."""
|
||||
handler = get_handler_module()
|
||||
|
||||
error = ValueError("test error message")
|
||||
error = ValueError('test error message')
|
||||
result = handler._make_rag_error_response(error, 'TestError')
|
||||
|
||||
# ActionResponse.error() returns code=1 (error status)
|
||||
@@ -36,7 +37,7 @@ class TestMakeRagErrorResponse:
|
||||
"""Test that error type is included in message."""
|
||||
handler = get_handler_module()
|
||||
|
||||
error = RuntimeError("something went wrong")
|
||||
error = RuntimeError('something went wrong')
|
||||
result = handler._make_rag_error_response(error, 'VectorStoreError')
|
||||
|
||||
assert '[VectorStoreError/RuntimeError]' in result.message
|
||||
@@ -45,7 +46,7 @@ class TestMakeRagErrorResponse:
|
||||
"""Test that extra context fields are included."""
|
||||
handler = get_handler_module()
|
||||
|
||||
error = Exception("embedding failed")
|
||||
error = Exception('embedding failed')
|
||||
result = handler._make_rag_error_response(
|
||||
error,
|
||||
'EmbeddingError',
|
||||
@@ -71,7 +72,7 @@ class TestMakeRagErrorResponse:
|
||||
"""Test multiple context fields are comma separated."""
|
||||
handler = get_handler_module()
|
||||
|
||||
error = IOError("file not found")
|
||||
error = IOError('file not found')
|
||||
result = handler._make_rag_error_response(
|
||||
error,
|
||||
'FileServiceError',
|
||||
@@ -119,9 +120,7 @@ class TestCleanupPluginData:
|
||||
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
|
||||
handler_instance.ap = mock_app
|
||||
|
||||
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(
|
||||
handler_instance, 'author', 'plugin-name'
|
||||
)
|
||||
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(handler_instance, 'author', 'plugin-name')
|
||||
|
||||
# Should have at least 2 calls: one for settings, one for binary storage
|
||||
assert mock_app.persistence_mgr.execute_async.call_count >= 2
|
||||
assert mock_app.persistence_mgr.execute_async.call_count >= 2
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
|
||||
def test_parse_plugin_id_accepts_author_name():
|
||||
|
||||
@@ -1 +1 @@
|
||||
|
||||
"""Provider requester tests"""
|
||||
|
||||
@@ -88,7 +88,10 @@ class AnotherFakeRequester(requester.ProviderAPIRequester):
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
return provider_message.Message(role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')])
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')]
|
||||
)
|
||||
|
||||
async def invoke_rerank(self, model, query: str, documents: list, extra_args={}):
|
||||
"""Return fake rerank results."""
|
||||
@@ -135,8 +138,10 @@ def mock_app_for_modelmgr():
|
||||
|
||||
# Fake persistence manager - returns empty results by default
|
||||
app.persistence_mgr = SimpleNamespace()
|
||||
|
||||
async def default_execute(query):
|
||||
return _make_mock_result([])
|
||||
|
||||
app.persistence_mgr.execute_async = AsyncMock(side_effect=default_execute)
|
||||
|
||||
# Fake discover engine
|
||||
@@ -165,9 +170,7 @@ def fake_requester_registry(mock_app_for_modelmgr):
|
||||
fake_component = _create_fake_component('fake-requester', FakeProviderAPIRequester)
|
||||
another_component = _create_fake_component('another-fake-requester', AnotherFakeRequester)
|
||||
|
||||
app.discover.get_components_by_kind = Mock(
|
||||
return_value=[fake_component, another_component]
|
||||
)
|
||||
app.discover.get_components_by_kind = Mock(return_value=[fake_component, another_component])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
return model_mgr
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Tests for AnthropicMessages requester.
|
||||
|
||||
Tests config and pure utility methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestAnthropicMessagesConfig:
|
||||
"""Tests for default config."""
|
||||
|
||||
def test_default_config_values(self):
|
||||
"""Check default_config."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
|
||||
|
||||
assert AnthropicMessages.default_config['base_url'] == 'https://api.anthropic.com'
|
||||
assert AnthropicMessages.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config can override defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
|
||||
|
||||
mock_app = MagicMock()
|
||||
req = AnthropicMessages(mock_app, {
|
||||
'base_url': 'https://custom.anthropic.com',
|
||||
'timeout': 60,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'https://custom.anthropic.com'
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Tests for requester error handling - direct import version.
|
||||
|
||||
Tests error handling branches by importing real packages and mocking
|
||||
only the necessary dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
import openai # Import real openai package
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestInvokeLLMErrorHandling:
|
||||
"""Tests for invoke_llm error handling branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock Application."""
|
||||
app = MagicMock()
|
||||
app.tool_mgr = MagicMock()
|
||||
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model(self):
|
||||
"""Create mock RuntimeLLMModel."""
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'gpt-4'
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Create mock provider message."""
|
||||
msg = MagicMock()
|
||||
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
|
||||
return msg
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
"""Create requester with mocked OpenAI client."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(mock_app, {
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 120,
|
||||
})
|
||||
|
||||
# Replace client with mock
|
||||
req.client = MagicMock()
|
||||
req.client.chat = MagicMock()
|
||||
req.client.chat.completions = MagicMock()
|
||||
req.client.chat.completions.create = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""TimeoutError is wrapped as RequesterError."""
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_request_context_length(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""BadRequestError with context_length_exceeded has special message."""
|
||||
error = openai.BadRequestError(
|
||||
message='context_length_exceeded: max 4096',
|
||||
response=MagicMock(status_code=400),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '上文过长' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""AuthenticationError shows invalid api-key message."""
|
||||
error = openai.AuthenticationError(
|
||||
message='Invalid API key',
|
||||
response=MagicMock(status_code=401),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert 'api-key' in str(exc.value).lower() or '无效' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""RateLimitError shows rate limit message."""
|
||||
error = openai.RateLimitError(
|
||||
message='Rate limit exceeded',
|
||||
response=MagicMock(status_code=429),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '频繁' in str(exc.value) or '余额' in str(exc.value)
|
||||
|
||||
|
||||
class TestInvokeEmbeddingErrorHandling:
|
||||
"""Tests for invoke_embedding error handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_model(self):
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'text-embedding-ada-002'
|
||||
model.model_entity.extra_args = {}
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(mock_app, {})
|
||||
req.client = MagicMock()
|
||||
req.client.embeddings = MagicMock()
|
||||
req.client.embeddings.create = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_timeout_error(self, requester_with_mocked_client, mock_embedding_model):
|
||||
"""TimeoutError in embedding request."""
|
||||
requester_with_mocked_client.client.embeddings.create = AsyncMock(
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_bad_request_error(self, requester_with_mocked_client, mock_embedding_model):
|
||||
"""BadRequestError in embedding request."""
|
||||
error = openai.BadRequestError(
|
||||
message='Invalid model',
|
||||
response=MagicMock(status_code=400),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.embeddings.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
)
|
||||
|
||||
assert '参数' in str(exc.value)
|
||||
|
||||
|
||||
class TestRequesterErrorClass:
|
||||
"""Tests for RequesterError."""
|
||||
|
||||
def test_error_message_prefix(self):
|
||||
"""RequesterError has '模型请求失败' prefix."""
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
error = RequesterError('test error')
|
||||
assert '模型请求失败' in str(error)
|
||||
|
||||
def test_error_is_exception(self):
|
||||
"""RequesterError inherits Exception."""
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
error = RequesterError('test')
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
"""Tests for requester default config."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Check default_config values."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
assert OpenAIChatCompletions.default_config['base_url'] == 'https://api.openai.com/v1'
|
||||
assert OpenAIChatCompletions.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config overrides defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(MagicMock(), {
|
||||
'base_url': 'https://custom.com/v1',
|
||||
'timeout': 60,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'https://custom.com/v1'
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Tests for requester pure utility functions.
|
||||
|
||||
Tests the helper methods in OpenAIChatCompletions that don't require network calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.utils.import_isolation import isolated_sys_modules
|
||||
|
||||
|
||||
class TestMaskApiKey:
|
||||
"""Tests for _mask_api_key method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
"""Create requester instance with mocked dependencies."""
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_mask_api_key_full(self):
|
||||
"""Mask a full API key."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('sk-1234567890abcdef')
|
||||
assert result == 'sk-1...cdef'
|
||||
|
||||
def test_mask_api_key_short(self):
|
||||
"""Mask a short API key (<=8 chars)."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('short')
|
||||
assert result == '****'
|
||||
|
||||
def test_mask_api_key_empty(self):
|
||||
"""Empty API key returns empty string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('')
|
||||
assert result == ''
|
||||
|
||||
def test_mask_api_key_none(self):
|
||||
"""None API key returns empty string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key(None)
|
||||
assert result == ''
|
||||
|
||||
def test_mask_api_key_exact_8_chars(self):
|
||||
"""API key with exactly 8 chars is masked as **** (<=8 threshold)."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('12345678')
|
||||
assert result == '****' # <= 8 chars gets masked
|
||||
|
||||
|
||||
class TestInferModelType:
|
||||
"""Tests for _infer_model_type method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_infer_embedding_from_name(self):
|
||||
"""Infer embedding type from model name."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
assert requester._infer_model_type('text-embedding-ada-002') == 'embedding'
|
||||
assert requester._infer_model_type('bge-large-en') == 'embedding'
|
||||
assert requester._infer_model_type('e5-base') == 'embedding'
|
||||
assert requester._infer_model_type('m3e-base') == 'embedding'
|
||||
|
||||
def test_infer_llm_from_name(self):
|
||||
"""Infer LLM type from model name."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
assert requester._infer_model_type('gpt-4') == 'llm'
|
||||
assert requester._infer_model_type('claude-3-opus') == 'llm'
|
||||
assert requester._infer_model_type('llama-2-70b') == 'llm'
|
||||
|
||||
def test_infer_model_type_none_id(self):
|
||||
"""Handle None model_id."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._infer_model_type(None)
|
||||
assert result == 'llm' # Default
|
||||
|
||||
def test_infer_model_type_empty_id(self):
|
||||
"""Handle empty model_id."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._infer_model_type('')
|
||||
assert result == 'llm' # Default
|
||||
|
||||
|
||||
class TestNormalizeModalities:
|
||||
"""Tests for _normalize_modalities method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_normalize_string_modality(self):
|
||||
"""Normalize single string modality."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text,image')
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_list_modalities(self):
|
||||
"""Normalize list of modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities(['text', 'image', 'audio'])
|
||||
assert result == ['text', 'image', 'audio']
|
||||
|
||||
def test_normalize_dict_modalities(self):
|
||||
"""Normalize dict with nested modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']})
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_none(self):
|
||||
"""Handle None input."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities(None)
|
||||
assert result == []
|
||||
|
||||
def test_normalize_arrow_separator(self):
|
||||
"""Handle arrow separator in modality string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text->image')
|
||||
assert result == ['text', 'image']
|
||||
|
||||
|
||||
class TestParseRerankResponse:
|
||||
"""Tests for _parse_rerank_response static method."""
|
||||
|
||||
def test_parse_cohere_jina_format(self):
|
||||
"""Parse Cohere/Jina/SiliconFlow format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'results': [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.80},
|
||||
]
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.80},
|
||||
]
|
||||
|
||||
def test_parse_voyage_format(self):
|
||||
"""Parse Voyage AI format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'data': [
|
||||
{'index': 0, 'relevance_score': 0.90},
|
||||
{'index': 2, 'relevance_score': 0.75},
|
||||
]
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.90},
|
||||
{'index': 2, 'relevance_score': 0.75},
|
||||
]
|
||||
|
||||
def test_parse_dashscope_format(self):
|
||||
"""Parse DashScope format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'output': {
|
||||
'results': [
|
||||
{'index': 0, 'relevance_score': 0.85},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [{'index': 0, 'relevance_score': 0.85}]
|
||||
|
||||
def test_parse_unknown_format(self):
|
||||
"""Handle unknown format returns empty list."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {'unknown_key': 'value'}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == []
|
||||
|
||||
def test_parse_empty_results(self):
|
||||
"""Handle empty results."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {'results': []}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestExtractScanMetadata:
|
||||
"""Tests for _extract_scan_metadata method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_extract_basic_metadata(self):
|
||||
"""Extract basic model metadata."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'gpt-4',
|
||||
'name': 'GPT-4 Turbo',
|
||||
'description': 'Most capable GPT-4 model',
|
||||
'context_length': 128000,
|
||||
'owned_by': 'openai',
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'gpt-4')
|
||||
|
||||
assert result['display_name'] == 'GPT-4 Turbo'
|
||||
assert result['description'] == 'Most capable GPT-4 model'
|
||||
assert result['context_length'] == 128000
|
||||
assert result['owned_by'] == 'openai'
|
||||
|
||||
def test_extract_metadata_missing_fields(self):
|
||||
"""Handle missing metadata fields."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {'id': 'unknown-model'}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'unknown-model')
|
||||
|
||||
assert result['display_name'] is None
|
||||
assert result['description'] is None
|
||||
assert result['context_length'] is None
|
||||
assert result['owned_by'] is None
|
||||
|
||||
def test_extract_metadata_top_provider_context(self):
|
||||
"""Extract context_length from top_provider."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'model',
|
||||
'top_provider': {
|
||||
'context_length': 4096,
|
||||
},
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'model')
|
||||
|
||||
assert result['context_length'] == 4096
|
||||
|
||||
def test_extract_metadata_empty_strings(self):
|
||||
"""Handle empty string values."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'model',
|
||||
'name': '', # Empty name
|
||||
'description': ' ', # Whitespace only
|
||||
'owned_by': '',
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'model')
|
||||
|
||||
assert result['display_name'] is None
|
||||
assert result['description'] is None
|
||||
assert result['owned_by'] is None
|
||||
|
||||
def test_extract_metadata_name_matches_id(self):
|
||||
"""When name equals id, display_name is None."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'gpt-4',
|
||||
'name': 'gpt-4', # Same as id
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'gpt-4')
|
||||
|
||||
assert result['display_name'] is None
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Tests for OllamaChatCompletions requester.
|
||||
|
||||
Tests model inference, payload construction, and error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestOllamaRequesterConfig:
|
||||
"""Tests for default config."""
|
||||
|
||||
def test_default_config_values(self):
|
||||
"""Check default_config."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
assert OllamaChatCompletions.default_config['base_url'] == 'http://127.0.0.1:11434'
|
||||
assert OllamaChatCompletions.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config can override defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
req = OllamaChatCompletions(mock_app, {
|
||||
'base_url': 'http://custom.ollama:11434',
|
||||
'timeout': 300,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'http://custom.ollama:11434'
|
||||
assert req.requester_cfg['timeout'] == 300
|
||||
|
||||
|
||||
class TestOllamaInferModelType:
|
||||
"""Tests for _infer_model_type pure function."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def test_infer_embedding_from_name(self, requester):
|
||||
"""Embedding keywords return 'embedding'."""
|
||||
assert requester._infer_model_type('nomic-embed-text') == 'embedding'
|
||||
assert requester._infer_model_type('bge-large') == 'embedding'
|
||||
assert requester._infer_model_type('text-embedding') == 'embedding'
|
||||
|
||||
def test_infer_llm_from_name(self, requester):
|
||||
"""Non-embedding keywords return 'llm'."""
|
||||
assert requester._infer_model_type('llama2') == 'llm'
|
||||
assert requester._infer_model_type('mistral') == 'llm'
|
||||
assert requester._infer_model_type('codellama') == 'llm'
|
||||
|
||||
def test_infer_model_type_none(self, requester):
|
||||
"""None model_id returns 'llm'."""
|
||||
assert requester._infer_model_type(None) == 'llm'
|
||||
|
||||
def test_infer_model_type_empty(self, requester):
|
||||
"""Empty model_id returns 'llm'."""
|
||||
assert requester._infer_model_type('') == 'llm'
|
||||
|
||||
|
||||
class TestOllamaInferModelAbilities:
|
||||
"""Tests for _infer_model_abilities pure function."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def test_infer_vision_ability(self, requester):
|
||||
"""Vision keywords add 'vision' ability."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'llava',
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'llava-v1.5')
|
||||
assert 'vision' in abilities
|
||||
|
||||
def test_infer_vision_from_model_id(self, requester):
|
||||
"""Vision keywords in model_id add 'vision' ability."""
|
||||
item = {}
|
||||
abilities = requester._infer_model_abilities(item, 'llava-7b')
|
||||
assert 'vision' in abilities
|
||||
|
||||
def test_infer_func_call_ability(self, requester):
|
||||
"""Tool/function keywords add 'func_call' ability."""
|
||||
item = {
|
||||
'details': {
|
||||
'families': ['tools'],
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'model')
|
||||
assert 'func_call' in abilities
|
||||
|
||||
def test_infer_no_abilities(self, requester):
|
||||
"""No matching keywords returns empty abilities."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'llama',
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'llama-2')
|
||||
assert len(abilities) == 0
|
||||
|
||||
def test_infer_multiple_abilities(self, requester):
|
||||
"""Multiple keywords can add multiple abilities."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'vision',
|
||||
'families': ['tools'],
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'vision-tool-model')
|
||||
assert 'vision' in abilities
|
||||
assert 'func_call' in abilities
|
||||
|
||||
|
||||
class TestOllamaMakeMessage:
|
||||
"""Tests for _make_msg response parsing."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def _create_ollama_response(self, content, tool_calls=None):
|
||||
"""Helper to create mock ollama response."""
|
||||
import ollama
|
||||
|
||||
mock_response = MagicMock(spec=ollama.ChatResponse)
|
||||
mock_message = MagicMock(spec=ollama.Message)
|
||||
mock_message.content = content
|
||||
mock_message.tool_calls = tool_calls
|
||||
mock_response.message = mock_message
|
||||
|
||||
return mock_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_text_content(self, requester):
|
||||
"""Text content is extracted."""
|
||||
mock_response = self._create_ollama_response('Hello world')
|
||||
|
||||
result = await requester._make_msg(mock_response)
|
||||
|
||||
assert result.content == 'Hello world'
|
||||
assert result.role == 'assistant'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_with_tool_calls(self, requester):
|
||||
"""Tool calls are parsed."""
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.function = MagicMock()
|
||||
mock_tool_call.function.name = 'get_weather'
|
||||
mock_tool_call.function.arguments = {'location': 'Beijing'}
|
||||
|
||||
mock_response = self._create_ollama_response('', tool_calls=[mock_tool_call])
|
||||
|
||||
result = await requester._make_msg(mock_response)
|
||||
|
||||
assert result.tool_calls is not None
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].function.name == 'get_weather'
|
||||
# Arguments should be JSON string
|
||||
assert isinstance(result.tool_calls[0].function.arguments, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_empty_message_raises(self, requester):
|
||||
"""Empty message raises ValueError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.message = None
|
||||
|
||||
with pytest.raises(ValueError, match='message'):
|
||||
await requester._make_msg(mock_response)
|
||||
|
||||
|
||||
class TestOllamaErrorHandling:
|
||||
"""Tests for error handling branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
app = MagicMock()
|
||||
app.tool_mgr = MagicMock()
|
||||
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
req = OllamaChatCompletions(mock_app, {})
|
||||
req.client = MagicMock()
|
||||
req.client.chat = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model(self):
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'llama2'
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
msg = MagicMock()
|
||||
msg.role = 'user'
|
||||
msg.content = 'test'
|
||||
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
|
||||
return msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""TimeoutError is converted to RequesterError."""
|
||||
requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
|
||||
class TestOllamaScanModels:
|
||||
"""Tests for scan_models method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
req = OllamaChatCompletions(mock_app, {
|
||||
'base_url': 'http://127.0.0.1:11434',
|
||||
'timeout': 120,
|
||||
})
|
||||
return req
|
||||
|
||||
def test_requester_name_constant(self):
|
||||
"""REQUESTER_NAME constant exists."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME
|
||||
|
||||
assert REQUESTER_NAME == 'ollama-chat'
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Unit tests for LiteLLMRequester._convert_messages.
|
||||
|
||||
Focus: the content-part normalization that (a) converts image_base64 parts to
|
||||
the OpenAI image_url shape and (b) drops non-image file parts (file_base64 /
|
||||
file_url) which OpenAI-compatible chat models reject. The latter is essential
|
||||
for Voice/File attachments — including ones replayed from conversation history —
|
||||
since the agent consumes their bytes via the sandbox, not the model payload.
|
||||
"""
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
|
||||
def _make_requester() -> LiteLLMRequester:
|
||||
# _convert_messages does not touch instance config, so bypass __init__.
|
||||
return LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_base64_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('analyze this audio'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
assert 'file_base64' not in types
|
||||
assert types == ['text']
|
||||
assert parts[0]['text'] == 'analyze this audio'
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_url_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('here is a doc'),
|
||||
provider_message.ContentElement.from_file_url('http://example.com/report.xlsx', 'report.xlsx'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
types = [p.get('type') for p in out[0]['content']]
|
||||
assert types == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_keeps_image_and_converts_to_image_url():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('look'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,AAAA'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
# image is preserved and reshaped to the OpenAI image_url form
|
||||
assert types == ['text', 'image_url']
|
||||
img_part = parts[1]
|
||||
assert img_part['image_url'] == {'url': 'data:image/png;base64,AAAA'}
|
||||
assert 'image_base64' not in img_part
|
||||
|
||||
|
||||
def test_convert_messages_mixed_history_strips_only_files():
|
||||
req = _make_requester()
|
||||
# Simulate replayed history: an old voice turn + a current text turn.
|
||||
history_voice = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('old audio turn'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,BBBB', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
current = provider_message.Message(
|
||||
role='user',
|
||||
content=[provider_message.ContentElement.from_text('now do the csv')],
|
||||
)
|
||||
out = req._convert_messages([history_voice, current])
|
||||
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||
assert [p.get('type') for p in out[1]['content']] == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_plain_string_content_untouched():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(role='user', content='just text')
|
||||
out = req._convert_messages([msg])
|
||||
assert out[0]['content'] == 'just text'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for LocalAgentRunner._inject_inbound_attachments.
|
||||
|
||||
Covers the user -> sandbox attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* materialized descriptors are stashed on the query and described to the model
|
||||
via an appended text note (in-sandbox paths + outbox convention);
|
||||
* non-image file parts (file_base64 / file_url) are stripped from the user
|
||||
message content because OpenAI-compatible chat models reject them, while
|
||||
image and text parts are kept for vision models;
|
||||
* the helper is a no-op when the box service is unavailable or yields nothing,
|
||||
and never raises into the chat turn on materialization failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
def _make_runner(box_service) -> LocalAgentRunner:
|
||||
runner = LocalAgentRunner.__new__(LocalAgentRunner)
|
||||
runner.ap = SimpleNamespace(logger=Mock(), box_service=box_service)
|
||||
return runner
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={}, query_id='q-123')
|
||||
|
||||
|
||||
def _box_service(attachments):
|
||||
svc = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(return_value=attachments),
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_strips_file_parts_and_appends_note():
|
||||
box = _box_service([{'type': 'Voice', 'path': '/inbox/q-123/voice.wav', 'size': 176000}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('transcribe this'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
# file_base64 dropped; text kept; sandbox-path note appended as text
|
||||
assert 'file_base64' not in types
|
||||
assert types.count('text') == 2
|
||||
note = user_message.content[-1].text
|
||||
assert '/inbox/q-123/voice.wav' in note
|
||||
assert '/outbox/q-123' in note
|
||||
# descriptors stashed for downstream stages
|
||||
assert query.variables['_sandbox_inbound_attachments'] == box.materialize_inbound_attachments.return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_keeps_image_parts():
|
||||
box = _box_service([{'type': 'Image', 'path': '/inbox/q-123/pic.png', 'size': 1234}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('what is this'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,iVBORw0K'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
assert 'image_base64' in types # vision part preserved
|
||||
assert types[-1] == 'text' # note appended last
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_promotes_string_content_to_list_with_note():
|
||||
box = _box_service([{'type': 'File', 'path': '/inbox/q-123/data.csv', 'size': 42}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='clean this csv')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert isinstance(user_message.content, list)
|
||||
assert [getattr(ce, 'type', None) for ce in user_message.content] == ['text', 'text']
|
||||
assert user_message.content[0].text == 'clean this csv'
|
||||
assert '/inbox/q-123/data.csv' in user_message.content[1].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_without_box_service():
|
||||
runner = _make_runner(box_service=None)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_when_no_attachments():
|
||||
box = _box_service([])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_swallows_materialization_error():
|
||||
box = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(side_effect=RuntimeError('disk full')),
|
||||
)
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
# must not raise
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
assert user_message.content == 'hello'
|
||||
runner.ap.logger.warning.assert_called_once()
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner, _StreamAccumulator
|
||||
|
||||
|
||||
class RecordingProvider:
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='Let me calculate that exactly.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='exec',
|
||||
arguments=json.dumps(
|
||||
{'command': ("python - <<'PY'\nnums = [1, 2, 3, 4]\nprint(sum(nums) / len(nums))\nPY")}
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
tool_result = json.loads(messages[-1].content)
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content=f'The average is {tool_result["stdout"]}.',
|
||||
)
|
||||
|
||||
|
||||
class RecordingStreamProvider:
|
||||
def __init__(self):
|
||||
self.stream_requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.stream_requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
if len(self.stream_requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='exec',
|
||||
arguments=json.dumps({'command': "python -c 'print(1)'"}),
|
||||
),
|
||||
)
|
||||
],
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Tool execution failed.',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def make_query() -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
|
||||
return pipeline_query.Query.model_construct(
|
||||
query_id='avg-query',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=[],
|
||||
message_event=None,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||
},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
},
|
||||
prompt=SimpleNamespace(messages=[]),
|
||||
messages=[],
|
||||
user_message=provider_message.Message(
|
||||
role='user',
|
||||
content='Please calculate the average of 1, 2, 3, and 4.',
|
||||
),
|
||||
use_funcs=[SimpleNamespace(name='exec')],
|
||||
use_llm_model_uuid='test-model-uuid',
|
||||
variables={},
|
||||
)
|
||||
|
||||
|
||||
def test_stream_accumulator_merges_fragmented_tool_call_arguments():
|
||||
accumulator = _StreamAccumulator(msg_sequence=1)
|
||||
|
||||
assert (
|
||||
accumulator.add(
|
||||
provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='{"command":'),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
emitted = accumulator.add(
|
||||
provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(name='exec', arguments='"pwd"}'),
|
||||
)
|
||||
],
|
||||
is_final=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert emitted is not None
|
||||
final_msg = accumulator.final_message()
|
||||
assert final_msg.tool_calls[0].function.name == 'exec'
|
||||
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_uses_exec_for_exact_calculation():
|
||||
provider = RecordingProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
tool_manager = SimpleNamespace(
|
||||
execute_func_call=AsyncMock(
|
||||
return_value={
|
||||
'session_id': 'avg-query',
|
||||
'backend': 'podman',
|
||||
'status': 'completed',
|
||||
'ok': True,
|
||||
'exit_code': 0,
|
||||
'stdout': '2.5',
|
||||
'stderr': '',
|
||||
'duration_ms': 18,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=tool_manager,
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(
|
||||
get_system_guidance=Mock(
|
||||
return_value=(
|
||||
'When the exec tool is available, use it for exact calculations, statistics, '
|
||||
'structured data parsing, and code execution instead of estimating mentally. '
|
||||
'Unless the user explicitly asks for the script, code, or implementation details, '
|
||||
'do not include the generated script in the final answer. '
|
||||
'A default workspace is mounted at /workspace for file tasks.'
|
||||
)
|
||||
),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert [message.role for message in results] == ['assistant', 'tool', 'assistant']
|
||||
assert results[-1].content == 'The average is 2.5.'
|
||||
|
||||
tool_manager.execute_func_call.assert_awaited_once()
|
||||
tool_name, tool_parameters = tool_manager.execute_func_call.await_args.args[:2]
|
||||
assert tool_name == 'exec'
|
||||
assert 'print(sum(nums) / len(nums))' in tool_parameters['command']
|
||||
|
||||
first_request = provider.requests[0]
|
||||
assert any(
|
||||
message.role == 'system'
|
||||
and 'exec' in str(message.content)
|
||||
and 'exact calculations' in str(message.content)
|
||||
and 'Unless the user explicitly asks for the script' in str(message.content)
|
||||
and '/workspace' in str(message.content)
|
||||
for message in first_request['messages']
|
||||
)
|
||||
assert [tool.name for tool in first_request['funcs']] == ['exec']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_streaming_tool_error_yields_message_chunks():
|
||||
provider = RecordingStreamProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
query = make_query()
|
||||
query.adapter = adapter
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(side_effect=RuntimeError('boom'))),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(
|
||||
get_system_guidance=Mock(return_value='sandbox guidance'),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
assert any(message.role == 'tool' and message.content == 'err: boom' for message in results)
|
||||
@@ -0,0 +1,957 @@
|
||||
"""Tests for MCP Box integration: path rewriting, host_path inference, config model, payloads.
|
||||
|
||||
Uses importlib.util.spec_from_file_location to load mcp.py directly without
|
||||
triggering the circular import chain through the app module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load mcp.py directly from file path, with stub dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_module(fqn: str, attrs: dict | None = None, is_package: bool = False):
|
||||
"""Create or return a stub module and register it in sys.modules."""
|
||||
if fqn in sys.modules:
|
||||
mod = sys.modules[fqn]
|
||||
else:
|
||||
mod = types.ModuleType(fqn)
|
||||
mod.__spec__ = importlib.machinery.ModuleSpec(fqn, None, is_package=is_package)
|
||||
if is_package:
|
||||
mod.__path__ = []
|
||||
sys.modules[fqn] = mod
|
||||
parts = fqn.rsplit('.', 1)
|
||||
if len(parts) == 2 and parts[0] in sys.modules:
|
||||
setattr(sys.modules[parts[0]], parts[1], mod)
|
||||
if attrs:
|
||||
for k, v in attrs.items():
|
||||
setattr(mod, k, v)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', autouse=True)
|
||||
def mcp_module():
|
||||
"""Load mcp.py with minimal stubs to avoid circular imports."""
|
||||
saved = {}
|
||||
|
||||
def _save_and_stub(name, attrs=None, is_package=False):
|
||||
saved[name] = sys.modules.get(name)
|
||||
# Don't overwrite modules that already exist (from other test modules)
|
||||
if name in sys.modules:
|
||||
return
|
||||
_stub_module(name, attrs, is_package)
|
||||
|
||||
# Stub entire dependency chains as packages / modules
|
||||
_save_and_stub('langbot_plugin', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api.entities', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api.entities.events', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api.entities.events.pipeline_query', {})
|
||||
_save_and_stub('langbot_plugin.api.entities.builtin', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api.entities.builtin.resource', is_package=True)
|
||||
_save_and_stub(
|
||||
'langbot_plugin.api.entities.builtin.resource.tool',
|
||||
{
|
||||
'LLMTool': type('LLMTool', (), {}),
|
||||
},
|
||||
)
|
||||
_save_and_stub('langbot_plugin.api.entities.builtin.provider', is_package=True)
|
||||
_save_and_stub('langbot_plugin.api.entities.builtin.provider.message', {})
|
||||
_save_and_stub('sqlalchemy', {'select': Mock()})
|
||||
_save_and_stub('httpx', {'AsyncClient': Mock()})
|
||||
_save_and_stub('mcp', {'ClientSession': Mock, 'StdioServerParameters': Mock}, is_package=True)
|
||||
_save_and_stub('mcp.client', is_package=True)
|
||||
_save_and_stub('mcp.client.stdio', {'stdio_client': Mock()})
|
||||
_save_and_stub('mcp.client.sse', {'sse_client': Mock()})
|
||||
_save_and_stub('mcp.client.streamable_http', {'streamable_http_client': Mock()})
|
||||
_save_and_stub('mcp.client.websocket', {'websocket_client': Mock()})
|
||||
|
||||
# Stub the provider.tools.loader (source of circular import)
|
||||
_save_and_stub('langbot', is_package=True)
|
||||
_save_and_stub('langbot.pkg', is_package=True)
|
||||
_save_and_stub('langbot.pkg.provider', is_package=True)
|
||||
_save_and_stub('langbot.pkg.provider.tools', is_package=True)
|
||||
_save_and_stub(
|
||||
'langbot.pkg.provider.tools.loader',
|
||||
{
|
||||
'ToolLoader': type('ToolLoader', (), {'__init__': lambda self, ap: None}),
|
||||
},
|
||||
)
|
||||
_save_and_stub('langbot.pkg.provider.tools.loaders', is_package=True)
|
||||
_save_and_stub('langbot.pkg.core', is_package=True)
|
||||
_save_and_stub('langbot.pkg.core.app', {'Application': type('Application', (), {})})
|
||||
_save_and_stub('langbot.pkg.entity', is_package=True)
|
||||
_save_and_stub('langbot.pkg.entity.persistence', is_package=True)
|
||||
_save_and_stub('langbot.pkg.entity.persistence.mcp', {})
|
||||
|
||||
# box models
|
||||
import enum as _enum
|
||||
|
||||
class _BPS(str, _enum.Enum):
|
||||
RUNNING = 'running'
|
||||
EXITED = 'exited'
|
||||
|
||||
_save_and_stub('langbot_plugin.box', is_package=True)
|
||||
_save_and_stub('langbot_plugin.box.models', {'BoxManagedProcessStatus': _BPS})
|
||||
|
||||
# Now load mcp.py via spec_from_file_location
|
||||
mod_fqn = 'langbot.pkg.provider.tools.loaders.mcp'
|
||||
sys.modules.pop(mod_fqn, None)
|
||||
mcp_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'langbot',
|
||||
'pkg',
|
||||
'provider',
|
||||
'tools',
|
||||
'loaders',
|
||||
'mcp.py',
|
||||
)
|
||||
mcp_path = os.path.normpath(mcp_path)
|
||||
pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(mcp_path))))
|
||||
sys.modules['langbot.pkg'].__path__ = [pkg_root]
|
||||
sys.modules['langbot.pkg.provider.tools.loaders'].__path__ = [os.path.dirname(mcp_path)]
|
||||
spec = importlib.util.spec_from_file_location(mod_fqn, mcp_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[mod_fqn] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
yield mod
|
||||
|
||||
# Cleanup
|
||||
sys.modules.pop(mod_fqn, None)
|
||||
sys.modules.pop('langbot.pkg.provider.tools.loaders.mcp_stdio', None)
|
||||
sys.modules.pop('langbot.pkg.box.workspace', None)
|
||||
for name in reversed(list(saved)):
|
||||
if saved[name] is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = saved[name]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ap():
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
ap.box_service = Mock()
|
||||
return ap
|
||||
|
||||
|
||||
def _make_session(mcp_module, server_config: dict, ap=None):
|
||||
if ap is None:
|
||||
ap = _make_ap()
|
||||
return mcp_module.RuntimeMCPSession(
|
||||
server_name=server_config.get('name', 'test-server'),
|
||||
server_config=server_config,
|
||||
enable=True,
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
|
||||
# ── MCPServerBoxConfig ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMCPServerBoxConfig:
|
||||
def test_default_values(self, mcp_module):
|
||||
cfg = mcp_module.MCPServerBoxConfig.model_validate({})
|
||||
assert cfg.image is None
|
||||
assert cfg.network == 'on'
|
||||
assert cfg.host_path is None
|
||||
assert cfg.host_path_mode == 'ro'
|
||||
assert cfg.env == {}
|
||||
assert cfg.startup_timeout_sec == 300
|
||||
assert cfg.cpus is None
|
||||
assert cfg.memory_mb is None
|
||||
assert cfg.pids_limit is None
|
||||
assert cfg.read_only_rootfs is None
|
||||
|
||||
def test_custom_values(self, mcp_module):
|
||||
cfg = mcp_module.MCPServerBoxConfig.model_validate(
|
||||
{
|
||||
'image': 'node:20',
|
||||
'network': 'on',
|
||||
'host_path': '/home/user/mcp',
|
||||
'host_path_mode': 'rw',
|
||||
'env': {'FOO': 'bar'},
|
||||
'startup_timeout_sec': 60,
|
||||
'cpus': 2.0,
|
||||
'memory_mb': 1024,
|
||||
'pids_limit': 256,
|
||||
'read_only_rootfs': False,
|
||||
}
|
||||
)
|
||||
assert cfg.image == 'node:20'
|
||||
assert cfg.network == 'on'
|
||||
assert cfg.cpus == 2.0
|
||||
assert cfg.memory_mb == 1024
|
||||
|
||||
def test_extra_fields_ignored(self, mcp_module):
|
||||
cfg = mcp_module.MCPServerBoxConfig.model_validate(
|
||||
{
|
||||
'image': 'node:20',
|
||||
'unknown_field': 'whatever',
|
||||
}
|
||||
)
|
||||
assert cfg.image == 'node:20'
|
||||
assert not hasattr(cfg, 'unknown_field')
|
||||
|
||||
|
||||
# ── Path Rewriting ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRewritePath:
|
||||
def test_no_host_path_returns_unchanged(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
assert s._rewrite_path('/some/path', None) == '/some/path'
|
||||
|
||||
def test_empty_path_returns_empty(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
assert s._rewrite_path('', '/home/user/mcp') == ''
|
||||
|
||||
def test_prefix_match_rewrites(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
result = s._rewrite_path('/home/user/mcp/server.py', '/home/user/mcp')
|
||||
assert result == '/workspace/server.py'
|
||||
|
||||
def test_exact_match_rewrites_to_workspace(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
result = s._rewrite_path('/home/user/mcp', '/home/user/mcp')
|
||||
assert result == '/workspace'
|
||||
|
||||
def test_non_matching_path_unchanged(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
result = s._rewrite_path('/opt/other/server.py', '/home/user/mcp')
|
||||
assert result == '/opt/other/server.py'
|
||||
|
||||
def test_similar_prefix_not_rewritten(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
result = s._rewrite_path('/home/user/mcp-other/file.py', '/home/user/mcp')
|
||||
assert result == '/home/user/mcp-other/file.py'
|
||||
|
||||
def test_nested_subpath_rewrites(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
result = s._rewrite_path('/home/user/mcp/src/lib/main.py', '/home/user/mcp')
|
||||
assert result == '/workspace/src/lib/main.py'
|
||||
|
||||
|
||||
# ── host_path Inference ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInferHostPath:
|
||||
def test_no_absolute_paths_returns_none(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': ['server.py'],
|
||||
},
|
||||
)
|
||||
assert s._infer_host_path() is None
|
||||
|
||||
def test_nonexistent_path_returns_none(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': '/nonexistent/path/to/python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
assert s._infer_host_path() is None
|
||||
|
||||
def test_existing_absolute_path_infers_directory(self, mcp_module):
|
||||
with tempfile.NamedTemporaryFile(suffix='.py') as f:
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [f.name],
|
||||
},
|
||||
)
|
||||
result = s._infer_host_path()
|
||||
assert result is not None
|
||||
assert result == os.path.dirname(os.path.realpath(f.name))
|
||||
|
||||
|
||||
# ── Build Box Session Payload ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBoxSessionPayload:
|
||||
def test_minimal_config(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
payload = s._build_box_session_payload('session-123')
|
||||
assert payload['session_id'] == 'session-123'
|
||||
assert payload['workdir'] == '/workspace'
|
||||
assert payload['env'] == {}
|
||||
assert 'host_path' not in payload
|
||||
|
||||
def test_with_host_path(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
'box': {'host_path': '/home/user/mcp', 'host_path_mode': 'ro'},
|
||||
},
|
||||
)
|
||||
payload = s._build_box_session_payload('session-123')
|
||||
assert payload['host_path'] == '/home/user/mcp'
|
||||
assert payload['host_path_mode'] == 'ro'
|
||||
|
||||
def test_optional_fields_included_when_set(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
'box': {'image': 'node:20', 'cpus': 2.0, 'memory_mb': 1024, 'pids_limit': 256},
|
||||
},
|
||||
)
|
||||
payload = s._build_box_session_payload('session-123')
|
||||
assert payload['image'] == 'node:20'
|
||||
assert payload['cpus'] == 2.0
|
||||
assert payload["memory_mb"] == 1024
|
||||
assert payload['pids_limit'] == 256
|
||||
|
||||
def test_none_fields_excluded(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
payload = s._build_box_session_payload('session-123')
|
||||
assert 'image' not in payload
|
||||
assert 'cpus' not in payload
|
||||
|
||||
|
||||
# ── Build Box Process Payload ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBoxProcessPayload:
|
||||
def test_basic_payload(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': ['server.py'],
|
||||
'env': {'KEY': 'val'},
|
||||
},
|
||||
)
|
||||
payload = s._build_box_process_payload()
|
||||
assert payload['command'] == 'python'
|
||||
assert payload['args'] == ['server.py']
|
||||
assert payload['env'] == {'KEY': 'val'}
|
||||
assert payload['cwd'] == '/workspace'
|
||||
|
||||
def test_path_rewriting_applied(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': '/home/user/mcp/venv/bin/python',
|
||||
'args': ['/home/user/mcp/server.py', '--config', '/home/user/mcp/config.json'],
|
||||
'env': {},
|
||||
'box': {'host_path': '/home/user/mcp'},
|
||||
},
|
||||
)
|
||||
payload = s._build_box_process_payload()
|
||||
# venv python is replaced with plain 'python' (deps installed in-container)
|
||||
assert payload['command'] == 'python'
|
||||
assert payload['args'] == ['/workspace/server.py', '--config', '/workspace/config.json']
|
||||
|
||||
def test_non_matching_args_not_rewritten(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': ['/opt/other/server.py', '--flag'],
|
||||
'env': {},
|
||||
'box': {'host_path': '/home/user/mcp'},
|
||||
},
|
||||
)
|
||||
payload = s._build_box_process_payload()
|
||||
assert payload['command'] == 'python'
|
||||
assert payload['args'] == ['/opt/other/server.py', '--flag']
|
||||
|
||||
|
||||
# ── Python Workspace Preparation ────────────────────────────────────
|
||||
|
||||
|
||||
class TestPythonWorkspacePreparation:
|
||||
def test_requirements_workspace_uses_venv_bootstrap(self, mcp_module, tmp_path):
|
||||
host_path = tmp_path / 'mcp-source'
|
||||
host_path.mkdir()
|
||||
(host_path / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8')
|
||||
|
||||
command = mcp_module.BoxStdioSessionRuntime.detect_install_command(
|
||||
str(host_path),
|
||||
'/workspace/.mcp/u1/workspace',
|
||||
)
|
||||
|
||||
assert command is not None
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'python -m pip install -r "/workspace/.mcp/u1/workspace/requirements.txt"' in command
|
||||
assert 'pip install --no-cache-dir -r' not in command
|
||||
|
||||
def test_staging_refresh_removes_stale_source_files_but_preserves_runtime_dirs(self, mcp_module, tmp_path):
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir()
|
||||
(source / 'server.py').write_text('print("new")\n', encoding='utf-8')
|
||||
(source / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8')
|
||||
(source / '.env').write_text('TOKEN=new\n', encoding='utf-8')
|
||||
|
||||
process_root = tmp_path / 'shared' / '.mcp' / 'u1'
|
||||
workspace = process_root / 'workspace'
|
||||
(workspace / '.venv' / 'bin').mkdir(parents=True)
|
||||
(workspace / '.venv' / 'bin' / 'python').write_text('', encoding='utf-8')
|
||||
(workspace / '.langbot').mkdir()
|
||||
(workspace / '.langbot' / 'python-env.lock').mkdir()
|
||||
(workspace / '.env').write_text('TOKEN=old\n', encoding='utf-8')
|
||||
(workspace / 'server.py').write_text('print("old")\n', encoding='utf-8')
|
||||
(workspace / 'removed.py').write_text('stale\n', encoding='utf-8')
|
||||
(workspace / 'removed_dir').mkdir()
|
||||
(workspace / 'removed_dir' / 'old.txt').write_text('stale\n', encoding='utf-8')
|
||||
|
||||
mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace))
|
||||
|
||||
assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n'
|
||||
assert (workspace / 'requirements.txt').read_text(encoding='utf-8') == 'mcp==1.26.0\n'
|
||||
assert (workspace / '.env').read_text(encoding='utf-8') == 'TOKEN=new\n'
|
||||
assert not (workspace / 'removed.py').exists()
|
||||
assert not (workspace / 'removed_dir').exists()
|
||||
assert (workspace / '.venv' / 'bin' / 'python').exists()
|
||||
assert (workspace / '.langbot' / 'python-env.lock').is_dir()
|
||||
|
||||
def test_staging_refresh_ignores_unlink_race(self, mcp_module, tmp_path, monkeypatch):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir()
|
||||
(source / 'server.py').write_text('print("new")\n', encoding='utf-8')
|
||||
|
||||
process_root = tmp_path / 'shared' / '.mcp' / 'u1'
|
||||
workspace = process_root / 'workspace'
|
||||
workspace.mkdir(parents=True)
|
||||
stale_file = workspace / 'removed.py'
|
||||
stale_file.write_text('stale\n', encoding='utf-8')
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def unlink_with_race(path):
|
||||
if os.fspath(path) == str(stale_file):
|
||||
real_unlink(path)
|
||||
raise FileNotFoundError(path)
|
||||
real_unlink(path)
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module.os, 'unlink', unlink_with_race)
|
||||
|
||||
mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace))
|
||||
|
||||
assert not stale_file.exists()
|
||||
assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n'
|
||||
|
||||
|
||||
# ── get_runtime_info_dict ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetRuntimeInfoDict:
|
||||
def test_non_stdio_session(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert info['status'] == 'connecting'
|
||||
assert 'box_session_id' not in info
|
||||
|
||||
def test_runtime_tools_include_parameters(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
s.functions = [
|
||||
SimpleNamespace(
|
||||
name='create-service',
|
||||
description='Create a service',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'project_id': {'type': 'string'},
|
||||
},
|
||||
'required': ['project_id'],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
info = s.get_runtime_info_dict()
|
||||
|
||||
assert info['tools'][0]['parameters']['properties']['project_id']['type'] == 'string'
|
||||
assert info['tools'][0]['parameters']['required'] == ['project_id']
|
||||
|
||||
def test_stdio_session_includes_box_info(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert info['box_session_id'] == 'mcp-shared'
|
||||
assert info['box_enabled'] is True
|
||||
|
||||
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
|
||||
"""A transient config-page "test" now shares the same 'mcp-shared' Box
|
||||
session as live servers (so a test reuses the running container / live
|
||||
process instead of a cold per-test session bootstrap). Isolation is at
|
||||
the PROCESS level: the test runs under its own process_id and only ever
|
||||
stops that process_id, so it cannot disturb another server's live
|
||||
process or the shared session itself."""
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
transient = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'gen-uuid-123',
|
||||
'mode': 'stdio',
|
||||
'command': 'uvx',
|
||||
'args': ['mcp-server-time'],
|
||||
'_transient': True,
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
live = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'time',
|
||||
'uuid': 'real-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'uvx',
|
||||
'args': ['mcp-server-time'],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
assert transient.is_transient is True
|
||||
assert live.is_transient is False
|
||||
# Both share ONE Box session ...
|
||||
assert transient._build_box_session_id() == 'mcp-shared'
|
||||
assert live._build_box_session_id() == 'mcp-shared'
|
||||
assert transient._build_box_session_id() == live._build_box_session_id()
|
||||
# ... but are isolated by distinct process_ids within that session.
|
||||
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
||||
|
||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
||||
"""Policy: when Box is configured but unavailable (disabled in config
|
||||
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
|
||||
``_init_stdio_python_server`` will raise a clear refusal at start
|
||||
time; until then, the runtime info simply omits box_session_id so the
|
||||
UI can render the disabled state cleanly."""
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert 'box_session_id' not in info
|
||||
assert 'box_enabled' not in info
|
||||
|
||||
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
del ap.box_service
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert 'box_session_id' not in info
|
||||
|
||||
|
||||
# ── Box config parsing ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBoxConfigParsing:
|
||||
def test_box_config_parsed_from_server_config(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
'box': {'image': 'node:20', 'host_path': '/home/user/mcp'},
|
||||
},
|
||||
)
|
||||
assert isinstance(s.box_config, mcp_module.MCPServerBoxConfig)
|
||||
assert s.box_config.image == 'node:20'
|
||||
assert s.box_config.host_path == '/home/user/mcp'
|
||||
|
||||
def test_missing_box_key_uses_defaults(self, mcp_module):
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'sse',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
)
|
||||
assert isinstance(s.box_config, mcp_module.MCPServerBoxConfig)
|
||||
assert s.box_config.image is None
|
||||
assert s.box_config.host_path_mode == 'ro'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
class FakeClientSession:
|
||||
def __init__(self, *_args):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def initialize(self):
|
||||
return None
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_websocket_client(_url: str):
|
||||
yield ('read-stream', 'write-stream')
|
||||
|
||||
mcp_stdio_module.ClientSession = FakeClientSession
|
||||
mcp_stdio_module.websocket_client = fake_websocket_client
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
ap.box_service.default_workspace = str(tmp_path / 'shared-box-workspace')
|
||||
ap.box_service.create_session = AsyncMock(return_value={})
|
||||
ap.box_service.build_spec = Mock(return_value='validated-spec')
|
||||
ap.box_service.client = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=SimpleNamespace(ok=True, stderr='', exit_code=0))
|
||||
)
|
||||
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box.example/process')
|
||||
|
||||
host_path = tmp_path / 'mcp-source'
|
||||
host_path.mkdir()
|
||||
server_file = host_path / 'server.py'
|
||||
server_file.write_text('print("hello")\n', encoding='utf-8')
|
||||
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'u1',
|
||||
'mode': 'stdio',
|
||||
'command': str(host_path / '.venv' / 'bin' / 'python'),
|
||||
'args': [str(server_file)],
|
||||
'box': {'host_path': str(host_path)},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
await session._init_box_stdio_server()
|
||||
await session.exit_stack.aclose()
|
||||
|
||||
assert ap.box_service.create_session.await_count == 1
|
||||
session_payload = ap.box_service.create_session.await_args.args[0]
|
||||
assert session_payload['session_id'] == 'mcp-shared'
|
||||
assert 'host_path' not in session_payload
|
||||
assert ap.box_service.build_spec.call_count == 1
|
||||
assert ap.box_service.build_spec.call_args.kwargs.get('skip_host_mount_validation', False) is False
|
||||
assert ap.box_service.build_spec.call_args.args[0]['host_path'] == str(host_path)
|
||||
|
||||
staged_file = tmp_path / 'shared-box-workspace' / '.mcp' / 'u1' / 'workspace' / 'server.py'
|
||||
assert staged_file.read_text(encoding='utf-8') == 'print("hello")\n'
|
||||
|
||||
process_payload = ap.box_service.start_managed_process.await_args.args[1]
|
||||
assert process_payload['process_id'] == 'u1'
|
||||
assert process_payload['command'] == 'python'
|
||||
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
||||
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
|
||||
"""During a slow (npx) cold start the handshake fails while the managed
|
||||
process is still alive. initialize() must raise _ColdStartRetry (so the
|
||||
outer lifecycle loop reuses the live process and retries without stopping it
|
||||
or consuming the fatal budget), NOT a fatal error."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
class ColdClientSession:
|
||||
def __init__(self, *_args):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def initialize(self):
|
||||
# Process still cold-starting: handshake fails.
|
||||
raise Exception('Connection closed')
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_websocket_client(_url: str):
|
||||
yield ('read-stream', 'write-stream')
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
|
||||
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
ap.box_service.create_session = AsyncMock(return_value={})
|
||||
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'slow',
|
||||
'uuid': 'slow-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'npx',
|
||||
'args': ['-y', 'some-mcp'],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
# Process is NOT exited (still cold-starting) and not yet running for reuse.
|
||||
async def _not_exited():
|
||||
return False
|
||||
|
||||
session._box_stdio_runtime._managed_process_has_exited = _not_exited
|
||||
|
||||
async def _not_running():
|
||||
return False
|
||||
|
||||
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||
|
||||
with pytest.raises(mcp_stdio_module._ColdStartRetry):
|
||||
await session._init_box_stdio_server()
|
||||
|
||||
# Process was started exactly once (the retry will reuse it, not rebuild).
|
||||
assert ap.box_service.start_managed_process.await_count == 1
|
||||
await session.exit_stack.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
|
||||
"""If the handshake fails AND the process has definitively exited, that is a
|
||||
real failure — initialize() must NOT swallow it as a cold-start retry."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
class DeadClientSession:
|
||||
def __init__(self, *_args):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def initialize(self):
|
||||
raise Exception('Connection closed')
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_websocket_client(_url: str):
|
||||
yield ('read-stream', 'write-stream')
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
|
||||
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
||||
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
ap.box_service.create_session = AsyncMock(return_value={})
|
||||
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
||||
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
||||
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
async def _exited():
|
||||
return True
|
||||
|
||||
session._box_stdio_runtime._managed_process_has_exited = _exited
|
||||
|
||||
async def _not_running():
|
||||
return False
|
||||
|
||||
session._box_stdio_runtime._managed_process_is_running = _not_running
|
||||
|
||||
with pytest.raises(Exception) as ei:
|
||||
await session._init_box_stdio_server()
|
||||
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
|
||||
await session.exit_stack.aclose()
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
|
||||
|
||||
|
||||
class _TransportProbe:
|
||||
def __init__(self, streamable_status: int | None) -> None:
|
||||
self.streamable_status = streamable_status
|
||||
self.streamable_posts = 0
|
||||
self.streamable_messages: list[str] = []
|
||||
self.sse_gets = 0
|
||||
self.sse_messages: list[str] = []
|
||||
self.streamable_request_started = asyncio.Event()
|
||||
self.release_streamable_request = asyncio.Event()
|
||||
self._sse_response: web.StreamResponse | None = None
|
||||
|
||||
async def handle_mcp_endpoint(self, request: web.Request) -> web.StreamResponse:
|
||||
if request.method == 'POST':
|
||||
self.streamable_posts += 1
|
||||
self.streamable_request_started.set()
|
||||
if self.streamable_status is None:
|
||||
await self.release_streamable_request.wait()
|
||||
return web.Response(status=204)
|
||||
if self.streamable_status == 200:
|
||||
message = await request.json()
|
||||
method = message.get('method', '')
|
||||
self.streamable_messages.append(method)
|
||||
if method == 'initialize':
|
||||
return web.json_response(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'id': message['id'],
|
||||
'result': {
|
||||
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
|
||||
'capabilities': {'tools': {}},
|
||||
'serverInfo': {'name': 'streamable-test', 'version': '1.0.0'},
|
||||
},
|
||||
}
|
||||
)
|
||||
if method == 'tools/list':
|
||||
return web.json_response(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'id': message['id'],
|
||||
'result': {
|
||||
'tools': [
|
||||
{
|
||||
'name': 'echo',
|
||||
'description': 'Echo test input',
|
||||
'inputSchema': {'type': 'object'},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return web.Response(status=202)
|
||||
return web.Response(status=self.streamable_status)
|
||||
|
||||
self.sse_gets += 1
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
self._sse_response = response
|
||||
await response.write(b'event: endpoint\ndata: /messages?session_id=test-session\n\n')
|
||||
try:
|
||||
while request.transport is not None and not request.transport.is_closing():
|
||||
await asyncio.sleep(0.05)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
return response
|
||||
|
||||
async def handle_sse_message(self, request: web.Request) -> web.Response:
|
||||
message = await request.json()
|
||||
method = message.get('method', '')
|
||||
self.sse_messages.append(method)
|
||||
|
||||
if method == 'initialize':
|
||||
response_message = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': message['id'],
|
||||
'result': {
|
||||
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
|
||||
'capabilities': {},
|
||||
'serverInfo': {'name': 'legacy-sse-test', 'version': '1.0.0'},
|
||||
},
|
||||
}
|
||||
assert self._sse_response is not None
|
||||
payload = json.dumps(response_message, separators=(',', ':'))
|
||||
await self._sse_response.write(f'event: message\ndata: {payload}\n\n'.encode())
|
||||
|
||||
return web.Response(status=202)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _transport_server(streamable_status: int | None):
|
||||
probe = _TransportProbe(streamable_status)
|
||||
application = web.Application()
|
||||
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
||||
application.router.add_post('/messages', probe.handle_sse_message)
|
||||
runner = web.AppRunner(application, shutdown_timeout=0.1)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, '127.0.0.1', 0)
|
||||
await site.start()
|
||||
server = cast(asyncio.Server, site._server)
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
try:
|
||||
yield probe, f'http://127.0.0.1:{port}/mcp'
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
|
||||
app = cast(Any, SimpleNamespace(logger=Mock()))
|
||||
return RuntimeMCPSession(
|
||||
'remote-transport-test',
|
||||
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
|
||||
True,
|
||||
app,
|
||||
)
|
||||
|
||||
|
||||
def _contains_http_status(exc: BaseException, status_code: int) -> bool:
|
||||
return any(
|
||||
isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == status_code
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
|
||||
)
|
||||
|
||||
|
||||
async def _close_session(session: RuntimeMCPSession) -> None:
|
||||
await session.exit_stack.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_real_streamable_http_success_keeps_session_usable():
|
||||
async with _transport_server(200) as (probe, url):
|
||||
session = _session(url)
|
||||
try:
|
||||
await session._init_remote_server()
|
||||
assert session.session is not None
|
||||
tools = await session.session.list_tools()
|
||||
assert [tool.name for tool in tools.tools] == ['echo']
|
||||
assert probe.streamable_posts >= 2
|
||||
assert probe.streamable_messages[:2] == ['initialize', 'notifications/initialized']
|
||||
assert 'tools/list' in probe.streamable_messages
|
||||
assert probe.sse_gets == 0
|
||||
finally:
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status_code', [400, 404, 405])
|
||||
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
|
||||
async with _transport_server(status_code) as (probe, url):
|
||||
session = _session(url)
|
||||
try:
|
||||
await session._init_remote_server()
|
||||
assert session.session is not None
|
||||
assert probe.streamable_posts == 1
|
||||
assert probe.sse_gets == 1
|
||||
assert 'initialize' in probe.sse_messages
|
||||
finally:
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status_code', [401, 403, 406, 415, 429, 500])
|
||||
async def test_remote_transport_real_non_compatibility_error_does_not_fallback(status_code: int):
|
||||
async with _transport_server(status_code) as (probe, url):
|
||||
session = _session(url)
|
||||
try:
|
||||
with pytest.raises(BaseException) as exc_info:
|
||||
await session._init_remote_server()
|
||||
assert _contains_http_status(exc_info.value, status_code)
|
||||
assert probe.streamable_posts == 1
|
||||
assert probe.sse_gets == 0
|
||||
finally:
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_real_timeout_does_not_fallback():
|
||||
async with _transport_server(None) as (probe, url):
|
||||
session = _session(url, timeout=0.05)
|
||||
try:
|
||||
with pytest.raises(BaseException) as exc_info:
|
||||
await session._init_remote_server()
|
||||
assert any(
|
||||
isinstance(leaf, httpx.TimeoutException)
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc_info.value)
|
||||
)
|
||||
assert probe.streamable_posts == 1
|
||||
assert probe.sse_gets == 0
|
||||
finally:
|
||||
probe.release_streamable_request.set()
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('error_type', [httpx.ConnectError, httpx.ConnectTimeout])
|
||||
async def test_remote_transport_connection_errors_do_not_fallback(error_type: type[httpx.RequestError]):
|
||||
request = httpx.Request('POST', 'https://unreachable.invalid/mcp')
|
||||
error = error_type('connection failed', request=request)
|
||||
session = _session(str(request.url))
|
||||
session._init_streamable_http_server = AsyncMock(side_effect=error)
|
||||
session._init_sse_server = AsyncMock()
|
||||
|
||||
with pytest.raises(type(error)) as exc_info:
|
||||
await session._init_remote_server()
|
||||
|
||||
assert exc_info.value is error
|
||||
session._init_sse_server.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fallback():
|
||||
async with _transport_server(None) as (probe, url):
|
||||
session = _session(url)
|
||||
task = asyncio.create_task(session._init_remote_server())
|
||||
await asyncio.wait_for(probe.streamable_request_started.wait(), timeout=2)
|
||||
task.cancel()
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert probe.sse_gets == 0
|
||||
finally:
|
||||
probe.release_streamable_request.set()
|
||||
await _close_session(session)
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
||||
MCP_RESOURCE_TRACE_QUERY_KEY,
|
||||
MCP_TOOL_LIST_RESOURCES,
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
MCPLoader,
|
||||
MCPSessionStatus,
|
||||
RuntimeMCPSession,
|
||||
)
|
||||
from langbot.pkg.telemetry import features as telemetry_features
|
||||
|
||||
|
||||
def _app() -> SimpleNamespace:
|
||||
return SimpleNamespace(logger=Mock())
|
||||
|
||||
|
||||
def _connected_session(
|
||||
*,
|
||||
name: str = 'docs',
|
||||
uuid: str = 'srv-1',
|
||||
resources: list[dict] | None = None,
|
||||
templates: list[dict] | None = None,
|
||||
) -> RuntimeMCPSession:
|
||||
session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app())
|
||||
session.status = MCPSessionStatus.CONNECTED
|
||||
session.session = SimpleNamespace(read_resource=AsyncMock())
|
||||
session.resources = resources or [
|
||||
{
|
||||
'uri': 'file:///README.md',
|
||||
'name': 'README.md',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'text/markdown',
|
||||
'size': None,
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
]
|
||||
session.resource_templates = templates or []
|
||||
return session
|
||||
|
||||
|
||||
def _query() -> SimpleNamespace:
|
||||
return SimpleNamespace(variables={})
|
||||
|
||||
|
||||
def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
|
||||
request = httpx.Request('POST', 'https://example.com/mcp')
|
||||
response = httpx.Response(status_code, request=request)
|
||||
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
|
||||
session = RuntimeMCPSession(
|
||||
'remote',
|
||||
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
|
||||
True,
|
||||
_app(),
|
||||
)
|
||||
session._init_streamable_http_server = AsyncMock(
|
||||
side_effect=ExceptionGroup('transport failed', [_http_status_error(405)])
|
||||
)
|
||||
session._init_sse_server = AsyncMock()
|
||||
|
||||
await session._init_remote_server()
|
||||
|
||||
session._init_streamable_http_server.assert_awaited_once()
|
||||
session._init_sse_server.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_does_not_fallback_for_auth_http_status():
|
||||
session = RuntimeMCPSession(
|
||||
'remote',
|
||||
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
|
||||
True,
|
||||
_app(),
|
||||
)
|
||||
error = _http_status_error(403)
|
||||
session._init_streamable_http_server = AsyncMock(side_effect=error)
|
||||
session._init_sse_server = AsyncMock()
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await session._init_remote_server()
|
||||
|
||||
session._init_streamable_http_server.assert_awaited_once()
|
||||
session._init_sse_server.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_truncates_caches_and_records_trace():
|
||||
session = _connected_session()
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='abcdef',
|
||||
)
|
||||
]
|
||||
)
|
||||
query = _query()
|
||||
|
||||
first = await session.read_resource_envelope(
|
||||
'file:///README.md',
|
||||
max_bytes=4,
|
||||
source='ui_preview',
|
||||
query=query,
|
||||
)
|
||||
second = await session.read_resource_envelope(
|
||||
'file:///README.md',
|
||||
max_bytes=4,
|
||||
source='agent_tool',
|
||||
query=query,
|
||||
)
|
||||
|
||||
assert first['contents'][0]['text'] == 'abcd'
|
||||
assert first['contents'][0]['bytes'] == 6
|
||||
assert first['truncated'] is True
|
||||
assert first['cache_hit'] is False
|
||||
assert second['cache_hit'] is True
|
||||
assert second['source'] == 'agent_tool'
|
||||
assert session.session.read_resource.await_count == 1
|
||||
|
||||
traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY]
|
||||
assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool']
|
||||
assert traces[1]['cache_hit'] is True
|
||||
assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == {
|
||||
'ui_preview': 1,
|
||||
'agent_tool': 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_shares_byte_budget_across_text_contents():
|
||||
session = _connected_session()
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md#first',
|
||||
mimeType='text/plain',
|
||||
text='abc',
|
||||
),
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md#second',
|
||||
mimeType='text/plain',
|
||||
text='def',
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4)
|
||||
|
||||
assert [item['text'] for item in envelope['contents']] == ['abc', 'd']
|
||||
assert envelope['contents'][0]['truncated'] is False
|
||||
assert envelope['contents'][1]['truncated'] is True
|
||||
assert envelope['bytes'] == 6
|
||||
assert envelope['truncated'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_omits_binary_by_default():
|
||||
session = _connected_session(
|
||||
resources=[
|
||||
{
|
||||
'uri': 'file:///image.png',
|
||||
'name': 'image.png',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'image/png',
|
||||
'size': 4,
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
]
|
||||
)
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.BlobResourceContents(
|
||||
uri='file:///image.png',
|
||||
mimeType='image/png',
|
||||
blob=base64.b64encode(b'\x00\x01\x02\x03').decode(),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
envelope = await session.read_resource_envelope('file:///image.png')
|
||||
|
||||
content = envelope['contents'][0]
|
||||
assert content['type'] == 'blob'
|
||||
assert content['blob'] is None
|
||||
assert content['bytes'] == 4
|
||||
assert content['binary_omitted'] is True
|
||||
assert envelope['truncated'] is True
|
||||
assert envelope['warnings'] == ['Binary resource content omitted from response.']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_rejects_unlisted_uri():
|
||||
session = _connected_session()
|
||||
|
||||
with pytest.raises(ValueError, match='Resource URI is not available'):
|
||||
await session.read_resource_envelope('file:///secret.txt')
|
||||
|
||||
session.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
def test_resource_uri_allowed_supports_listed_templates_conservatively():
|
||||
session = _connected_session(
|
||||
resources=[],
|
||||
templates=[
|
||||
{
|
||||
'uri_template': 'repo://{owner}/{repo}/file/{path}',
|
||||
'name': 'repository file',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'text/plain',
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True
|
||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False
|
||||
assert session.resource_uri_allowed('https://example.com/secret') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_can_hide_synthetic_resource_tools():
|
||||
loader = MCPLoader(_app())
|
||||
session = _connected_session()
|
||||
loader.sessions = {'docs': session}
|
||||
|
||||
with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True)
|
||||
without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False)
|
||||
|
||||
assert {tool.name for tool in with_resource_tools} == {
|
||||
MCP_TOOL_LIST_RESOURCES,
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
}
|
||||
assert without_resource_tools == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
|
||||
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,
|
||||
)
|
||||
|
||||
assert result[0].text == 'Error: MCP resource agent reads are disabled.'
|
||||
session.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources():
|
||||
loader = MCPLoader(_app())
|
||||
docs = _connected_session(name='docs', uuid='srv-1')
|
||||
docs.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='LangBot MCP resource context',
|
||||
)
|
||||
]
|
||||
)
|
||||
other = _connected_session(name='other', uuid='srv-2')
|
||||
other.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='must not be injected',
|
||||
)
|
||||
]
|
||||
)
|
||||
loader.sessions = {'docs': docs, 'other': other}
|
||||
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'},
|
||||
{'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
context = await loader.build_resource_context_for_query(query)
|
||||
|
||||
assert '<mcp_resource ' in context
|
||||
assert 'server="docs"' in context
|
||||
assert 'LangBot MCP resource context' in context
|
||||
assert 'must not be injected' not in context
|
||||
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()
|
||||
@@ -494,6 +494,7 @@ async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_reg
|
||||
'api_keys': ['temp-key'],
|
||||
},
|
||||
'abilities': ['func_call'],
|
||||
'context_length': 128000,
|
||||
'extra_args': {'temperature': 0.5},
|
||||
}
|
||||
|
||||
@@ -501,6 +502,9 @@ async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_reg
|
||||
|
||||
assert runtime_model.model_entity.uuid == 'temp-model-uuid'
|
||||
assert runtime_model.model_entity.name == 'TempModel'
|
||||
assert runtime_model.model_entity.context_length == 128000
|
||||
assert runtime_model.model_entity.extra_args == {'temperature': 0.5}
|
||||
assert 'context_length' not in runtime_model.model_entity.extra_args
|
||||
assert runtime_model.provider.provider_entity.uuid == 'temp-provider-uuid'
|
||||
assert runtime_model.provider.token_mgr.tokens == ['temp-key']
|
||||
|
||||
@@ -631,7 +635,9 @@ async def test_model_manager_reload_provider_not_found(fake_requester_registry):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_llm_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_llm_model_with_provider(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_llm_model_with_provider creates RuntimeLLMModel."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
@@ -644,7 +650,9 @@ async def test_model_manager_load_llm_model_with_provider(fake_requester_registr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_llm_model_with_provider_from_row(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_llm_model_with_provider_from_row(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_llm_model_with_provider handles Row objects."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
@@ -657,7 +665,9 @@ async def test_model_manager_load_llm_model_with_provider_from_row(fake_requeste
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_embedding_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_embedding_model_with_provider(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_embedding_model_with_provider creates RuntimeEmbeddingModel."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
@@ -785,4 +795,4 @@ def test_provider_not_found_error_str():
|
||||
error = provider_errors.ProviderNotFoundError('test-provider')
|
||||
|
||||
assert str(error) == 'Provider test-provider not found'
|
||||
assert error.provider_name == 'test-provider'
|
||||
assert error.provider_name == 'test-provider'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -16,8 +17,6 @@ from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
||||
from langbot.pkg.provider.modelmgr import requester
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
|
||||
from langbot.pkg.provider.modelmgr.token import TokenManager
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
@@ -91,71 +90,25 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||
captured_kwargs = {}
|
||||
async def test_model_manager_initialize_skips_space_sync_after_timeout():
|
||||
ap = SimpleNamespace()
|
||||
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
|
||||
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
|
||||
ap.logger = Mock()
|
||||
|
||||
def fake_client(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(**kwargs)
|
||||
mgr = ModelManager(ap)
|
||||
mgr.load_models_from_db = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
|
||||
async def slow_sync():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||
await requester_inst.initialize()
|
||||
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
|
||||
|
||||
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
|
||||
await mgr.initialize()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||
captured_kwargs = {}
|
||||
|
||||
def fake_client(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
|
||||
|
||||
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
|
||||
await requester_inst.initialize()
|
||||
|
||||
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_call_overrides_placeholder_api_key():
|
||||
captured_request = {}
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_request['api_key'] = fake_client.api_key
|
||||
captured_request['kwargs'] = kwargs
|
||||
return SimpleNamespace(
|
||||
data=[SimpleNamespace(embedding=[0.1, 0.2])],
|
||||
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
|
||||
)
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
api_key=OpenAIChatCompletions.init_api_key,
|
||||
embeddings=SimpleNamespace(create=fake_create),
|
||||
)
|
||||
|
||||
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||
requester_inst.client = fake_client
|
||||
|
||||
embeddings, usage_info = await requester_inst.invoke_embedding(
|
||||
model=requester.RuntimeEmbeddingModel(
|
||||
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
|
||||
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
|
||||
),
|
||||
input_text=['hello'],
|
||||
)
|
||||
|
||||
assert captured_request['api_key'] == 'runtime-key'
|
||||
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
|
||||
assert embeddings == [[0.1, 0.2]]
|
||||
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
|
||||
mgr.load_models_from_db.assert_awaited_once()
|
||||
mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
ap.logger.warning.assert_any_call('LangBot Space model sync timed out after 0.01s, skipping startup sync.')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -169,6 +122,7 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
|
||||
ap.logger = Mock()
|
||||
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
|
||||
ap.skill_mgr = None # PreProcessor only uses skill_mgr for the local-agent skill-binding branch
|
||||
ap.plugin_connector = SimpleNamespace(
|
||||
emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[])))
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Unit tests for provider_specific_fields round-trip in LiteLLMRequester.
|
||||
|
||||
This tests the fix for GitHub issue #1899: Gemini requires thought_signature
|
||||
to be preserved across tool call rounds for function calls to work correctly.
|
||||
"""
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
|
||||
def _make_requester() -> LiteLLMRequester:
|
||||
# _convert_messages and _normalize_stream_tool_calls do not touch instance config.
|
||||
return LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
|
||||
|
||||
def test_convert_messages_preserves_tool_call_provider_specific_fields():
|
||||
"""Tool calls should retain provider_specific_fields through _convert_messages."""
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content=None,
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call_123',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='search',
|
||||
arguments='{"query": "test"}',
|
||||
),
|
||||
provider_specific_fields={
|
||||
'thought_signature': 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ==',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert len(out) == 1
|
||||
assert out[0]['tool_calls'] is not None
|
||||
assert len(out[0]['tool_calls']) == 1
|
||||
|
||||
tc = out[0]['tool_calls'][0]
|
||||
assert tc['id'] == 'call_123'
|
||||
assert tc['function']['name'] == 'search'
|
||||
assert 'provider_specific_fields' in tc
|
||||
assert tc['provider_specific_fields']['thought_signature'] == 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ=='
|
||||
|
||||
|
||||
def test_convert_messages_preserves_message_provider_specific_fields():
|
||||
"""Messages should retain provider_specific_fields through _convert_messages."""
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content='Hello',
|
||||
provider_specific_fields={
|
||||
'thought_signatures': ['sig1', 'sig2'],
|
||||
},
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert len(out) == 1
|
||||
assert 'provider_specific_fields' in out[0]
|
||||
assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2']
|
||||
|
||||
|
||||
def test_normalize_stream_tool_calls_preserves_provider_specific_fields():
|
||||
"""Streaming tool calls should retain provider_specific_fields."""
|
||||
req = _make_requester()
|
||||
tool_call_state: dict[int, dict] = {}
|
||||
|
||||
# Simulate first chunk with id and type
|
||||
raw_tool_calls_1 = [
|
||||
{
|
||||
'index': 0,
|
||||
'id': 'call_abc',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'get_weather',
|
||||
'arguments': '',
|
||||
},
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'dGVzdF9zaWduYXR1cmU=',
|
||||
},
|
||||
},
|
||||
]
|
||||
result_1 = req._normalize_stream_tool_calls(raw_tool_calls_1, tool_call_state)
|
||||
assert result_1 is not None
|
||||
assert len(result_1) == 1
|
||||
assert result_1[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
||||
|
||||
# Simulate second chunk without provider_specific_fields (should be retained from state)
|
||||
raw_tool_calls_2 = [
|
||||
{
|
||||
'index': 0,
|
||||
'function': {
|
||||
'arguments': '{"city": "Tokyo"}',
|
||||
},
|
||||
},
|
||||
]
|
||||
result_2 = req._normalize_stream_tool_calls(raw_tool_calls_2, tool_call_state)
|
||||
assert result_2 is not None
|
||||
assert len(result_2) == 1
|
||||
# Should retain the provider_specific_fields from the first chunk
|
||||
assert result_2[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU='
|
||||
assert result_2[0]['function']['arguments'] == '{"city": "Tokyo"}'
|
||||
|
||||
|
||||
def test_normalize_stream_tool_calls_merges_function_level_psf():
|
||||
"""Function-level provider_specific_fields should be merged into tool-level."""
|
||||
req = _make_requester()
|
||||
tool_call_state: dict[int, dict] = {}
|
||||
|
||||
raw_tool_calls = [
|
||||
{
|
||||
'index': 0,
|
||||
'id': 'call_xyz',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'search',
|
||||
'arguments': '{}',
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'ZnVuY19sZXZlbF9zaWc=',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
result = req._normalize_stream_tool_calls(raw_tool_calls, tool_call_state)
|
||||
assert result is not None
|
||||
assert result[0]['provider_specific_fields']['thought_signature'] == 'ZnVuY19sZXZlbF9zaWc='
|
||||
|
||||
|
||||
def test_tool_call_roundtrip_through_message_entity():
|
||||
"""Full round-trip: LiteLLM response dict -> Message entity -> _convert_messages."""
|
||||
# Simulate what LiteLLM returns for a Gemini tool call response
|
||||
message_data = {
|
||||
'role': 'assistant',
|
||||
'content': None,
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_gemini_123',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'web_search',
|
||||
'arguments': '{"query": "test"}',
|
||||
},
|
||||
'provider_specific_fields': {
|
||||
'thought_signature': 'Z2VtaW5pX3NpZ25hdHVyZQ==',
|
||||
},
|
||||
},
|
||||
],
|
||||
'provider_specific_fields': {
|
||||
'thought_signatures': ['Z2VtaW5pX3NpZ25hdHVyZQ=='],
|
||||
},
|
||||
}
|
||||
|
||||
# Parse into Message entity (this is what invoke_llm does)
|
||||
msg = provider_message.Message(**message_data)
|
||||
|
||||
# Verify the entity has the fields
|
||||
assert msg.tool_calls is not None
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].provider_specific_fields is not None
|
||||
assert msg.tool_calls[0].provider_specific_fields['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
||||
assert msg.provider_specific_fields is not None
|
||||
assert msg.provider_specific_fields['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|
||||
|
||||
# Convert back to dict for LiteLLM (this is what _convert_messages does)
|
||||
req = _make_requester()
|
||||
out = req._convert_messages([msg])
|
||||
|
||||
# Verify the fields are preserved in the output
|
||||
assert out[0]['tool_calls'][0]['provider_specific_fields']['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ=='
|
||||
assert out[0]['provider_specific_fields']['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ==']
|
||||
@@ -43,6 +43,7 @@ class TestableRequester(requester.ProviderAPIRequester):
|
||||
remove_think=False,
|
||||
):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content=[provider_message.ContentElement(type='text', text='Testable response')],
|
||||
@@ -289,7 +290,9 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
result = await provider.invoke_llm(query, runtime_llm_model, messages)
|
||||
|
||||
@@ -330,7 +333,9 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
chunks = []
|
||||
async for chunk in provider.invoke_llm_stream(query, runtime_llm_model, messages):
|
||||
@@ -576,7 +581,9 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
with pytest.raises(RequesterError):
|
||||
await provider.invoke_llm(query, model, messages)
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests cover:
|
||||
- Conversation creation with prompts
|
||||
- Session concurrency semaphore
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -60,11 +61,7 @@ class TestSessionManagerGetSession:
|
||||
"""Create mock app with instance config."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'concurrency': {
|
||||
'session': 5
|
||||
}
|
||||
}
|
||||
mock_app.instance_config.data = {'concurrency': {'session': 5}}
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
@@ -173,11 +170,7 @@ class TestSessionManagerGetConversation:
|
||||
"""Create mock app with instance config."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'concurrency': {
|
||||
'session': 5
|
||||
}
|
||||
}
|
||||
mock_app.instance_config.data = {'concurrency': {'session': 5}}
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
@@ -201,17 +194,13 @@ class TestSessionManagerGetConversation:
|
||||
return query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_conversation_with_prompt(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_creates_conversation_with_prompt(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that get_conversation creates conversation with prompt."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
pipeline_uuid = 'pipeline-123'
|
||||
bot_uuid = 'bot-123'
|
||||
|
||||
@@ -234,21 +223,15 @@ class TestSessionManagerGetConversation:
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
pipeline_uuid = 'pipeline-123'
|
||||
bot_uuid = 'bot-123'
|
||||
|
||||
# First call creates conversation
|
||||
conv1 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
|
||||
)
|
||||
conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid)
|
||||
|
||||
# Second call with same pipeline should return same conversation
|
||||
conv2 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
|
||||
)
|
||||
conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid)
|
||||
|
||||
assert conv1 is conv2
|
||||
assert len(sample_session.conversations) == 1
|
||||
@@ -262,36 +245,26 @@ class TestSessionManagerGetConversation:
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
|
||||
# First call with pipeline1
|
||||
conv1 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1'
|
||||
)
|
||||
conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1')
|
||||
|
||||
# Second call with different pipeline should create new conversation
|
||||
conv2 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2'
|
||||
)
|
||||
conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2')
|
||||
|
||||
assert conv1 is not conv2
|
||||
assert len(sample_session.conversations) == 2
|
||||
assert sample_session.using_conversation is conv2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_has_empty_messages(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_conversation_has_empty_messages(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that created conversation has empty messages list."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
|
||||
conversation = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
|
||||
@@ -300,22 +273,17 @@ class TestSessionManagerGetConversation:
|
||||
assert conversation.messages == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_messages_from_config(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_prompt_messages_from_config(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that prompt messages are created from prompt_config."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'System message'},
|
||||
{'role': 'user', 'content': 'User message'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'System message'}, {'role': 'user', 'content': 'User message'}]
|
||||
|
||||
conversation = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
|
||||
)
|
||||
|
||||
assert conversation.prompt.name == 'default'
|
||||
assert len(conversation.prompt.messages) == 2
|
||||
assert len(conversation.prompt.messages) == 2
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_ap(logger=None):
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = logger or Mock()
|
||||
ap.persistence_mgr = Mock()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
|
||||
ap.persistence_mgr.serialize_model = Mock(side_effect=lambda cls, row: row)
|
||||
return ap
|
||||
|
||||
|
||||
def _make_skill_data(
|
||||
name='test-skill',
|
||||
instructions='Do something',
|
||||
package_root='',
|
||||
entry_file='SKILL.md',
|
||||
**kwargs,
|
||||
):
|
||||
return {
|
||||
'name': name,
|
||||
'display_name': kwargs.pop('display_name', name),
|
||||
'description': kwargs.pop('description', f'Description of {name}'),
|
||||
'instructions': instructions,
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
class TestSkillManagerCache:
|
||||
"""The Box runtime is the only source of truth — SkillManager just holds
|
||||
an in-memory cache populated by ``reload_skills``. There is no local
|
||||
filesystem reader anymore."""
|
||||
|
||||
def test_refresh_skill_from_disk_reports_cache_presence(self):
|
||||
"""Box is the only source of truth for skill content. refresh_skill_from_disk
|
||||
now just reports whether the skill is still in the in-memory cache —
|
||||
the actual content refresh is driven by SkillService awaiting
|
||||
``reload_skills`` after every Box mutation."""
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
# Empty cache → returns False
|
||||
assert mgr.refresh_skill_from_disk('test-skill') is False
|
||||
|
||||
# Cache populated → returns True; method does NOT mutate the cache
|
||||
cached = _make_skill_data(name='test-skill', instructions='Cached')
|
||||
mgr.skills['test-skill'] = cached
|
||||
assert mgr.refresh_skill_from_disk('test-skill') is True
|
||||
assert mgr.skills['test-skill'] is cached
|
||||
assert mgr.refresh_skill_from_disk('') is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):
|
||||
"""When LangBot shares a filesystem with Box (local stdio mode) and Box
|
||||
reports a skill whose package_root is gone from that shared filesystem,
|
||||
the cache must drop it instead of keeping a stale entry that would later
|
||||
produce a bad mount."""
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as live_dir:
|
||||
ghost_dir = os.path.join(live_dir, '_does_not_exist')
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=True,
|
||||
list_skills=AsyncMock(
|
||||
return_value=[
|
||||
_make_skill_data(name='alive', package_root=live_dir),
|
||||
_make_skill_data(name='ghost', package_root=ghost_dir),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = box_service
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
await mgr.reload_skills()
|
||||
|
||||
assert list(mgr.skills) == ['alive']
|
||||
# Warning fired with the dropped skill name so operators can see it.
|
||||
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
|
||||
assert any('ghost' in msg and 'package_root missing' in msg for msg in warning_messages)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_trusts_box_paths_when_filesystem_not_shared(self):
|
||||
"""In separated deployments (Docker Compose, k8s sidecar,
|
||||
--standalone-box, remote endpoint) the package_root reported by Box
|
||||
lives on the Box runtime's filesystem and is not resolvable on the
|
||||
LangBot side. The cache must keep every Box-reported skill rather than
|
||||
dropping them all via a local isdir() check."""
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
list_skills=AsyncMock(
|
||||
return_value=[
|
||||
_make_skill_data(name='alpha', package_root='/box/skills/alpha'),
|
||||
_make_skill_data(name='beta', package_root='/box/skills/beta'),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = box_service
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
await mgr.reload_skills()
|
||||
|
||||
assert sorted(mgr.skills) == ['alpha', 'beta']
|
||||
# No skill dropped → no "package_root missing" warning.
|
||||
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
|
||||
assert not any('package_root missing' in msg for msg in warning_messages)
|
||||
|
||||
|
||||
class TestSkillActivationHelper:
|
||||
"""Skill activation is now Tool-Call based.
|
||||
|
||||
The legacy text-marker mechanism (``[ACTIVATE_SKILL: x]`` detection,
|
||||
``build_activation_prompt_for_skills``, ``remove_activation_marker``,
|
||||
``prepare_skill_activation``) has been removed. Activation now goes
|
||||
through ``skill.activation.register_activated_skill``, invoked by the
|
||||
``activate`` Tool Call.
|
||||
"""
|
||||
|
||||
def test_register_activated_skill_records_known_skill(self):
|
||||
from langbot.pkg.skill.activation import register_activated_skill
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
|
||||
}
|
||||
ap.skill_mgr = mgr
|
||||
|
||||
query = SimpleNamespace(variables={})
|
||||
|
||||
assert register_activated_skill(ap, query, 'primary') is True
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary'}
|
||||
assert query.variables[ACTIVATED_SKILLS_KEY]['primary']['name'] == 'primary'
|
||||
|
||||
def test_register_activated_skill_rejects_unknown_skill(self):
|
||||
from langbot.pkg.skill.activation import register_activated_skill
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {'primary': _make_skill_data(name='primary')}
|
||||
ap.skill_mgr = mgr
|
||||
|
||||
query = SimpleNamespace(variables={})
|
||||
|
||||
assert register_activated_skill(ap, query, 'missing') is False
|
||||
assert ACTIVATED_SKILLS_KEY not in query.variables
|
||||
|
||||
def test_register_activated_skill_without_skill_manager_returns_false(self):
|
||||
from langbot.pkg.skill.activation import register_activated_skill
|
||||
|
||||
ap = _make_ap() # no skill_mgr attribute
|
||||
query = SimpleNamespace(variables={})
|
||||
|
||||
assert register_activated_skill(ap, query, 'primary') is False
|
||||
|
||||
|
||||
class TestSkillPathHelpers:
|
||||
def test_get_visible_skills_filters_by_bound_names(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={
|
||||
'visible': _make_skill_data(name='visible'),
|
||||
'hidden': _make_skill_data(name='hidden'),
|
||||
}
|
||||
)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
result = get_visible_skills(ap, query)
|
||||
|
||||
assert list(result.keys()) == ['visible']
|
||||
|
||||
def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
ACTIVATED_SKILLS_KEY,
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
get_activated_skill_names,
|
||||
restore_activated_skills,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={
|
||||
'visible': _make_skill_data(name='visible'),
|
||||
'hidden': _make_skill_data(name='hidden'),
|
||||
}
|
||||
)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', ''])
|
||||
|
||||
assert restored == ['visible']
|
||||
assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible']
|
||||
assert get_activated_skill_names(query) == ['visible']
|
||||
|
||||
def test_resolve_virtual_skill_path_allows_visible_skill_reads(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
resolve_virtual_skill_path,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
skill, rewritten = resolve_virtual_skill_path(
|
||||
ap,
|
||||
query,
|
||||
'/workspace/.skills/demo/SKILL.md',
|
||||
include_visible=True,
|
||||
include_activated=False,
|
||||
)
|
||||
|
||||
assert skill['name'] == 'demo'
|
||||
assert rewritten == '/workspace/SKILL.md'
|
||||
|
||||
def test_build_skill_session_id_uses_name_based_identifier(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import build_skill_session_id
|
||||
|
||||
with_launcher = build_skill_session_id(
|
||||
{'name': 'writer'},
|
||||
SimpleNamespace(query_id=42, launcher_type='person', launcher_id='123'),
|
||||
)
|
||||
fallback = build_skill_session_id({'name': 'writer'}, SimpleNamespace(query_id=99))
|
||||
|
||||
assert with_launcher == 'skill-person_123-writer'
|
||||
assert fallback == 'skill-99-writer'
|
||||
|
||||
def test_should_prepare_skill_python_env_detects_manifests_and_venv(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import should_prepare_skill_python_env
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
assert should_prepare_skill_python_env(tmpdir) is False
|
||||
|
||||
with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('requests==2.32.0\n')
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.makedirs(os.path.join(tmpdir, '.venv'))
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
def test_wrap_skill_command_with_python_env_bootstraps_then_runs_command(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
|
||||
|
||||
command = wrap_skill_command_with_python_env('python scripts/run.py')
|
||||
|
||||
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
|
||||
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python scripts/run.py')
|
||||
|
||||
|
||||
class TestSkillToolLoader:
|
||||
"""The skill tool surface is now just ``activate`` + ``register_skill``.
|
||||
|
||||
The legacy CRUD authoring tools (create/list/get/update/delete/
|
||||
import_skill_from_directory/reload_skills) were removed; skill CRUD is
|
||||
handled by SkillService via the HTTP API / web UI instead.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_returns_instructions_and_registers_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
|
||||
skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={'demo': skill},
|
||||
get_skill_by_name=lambda name: skill if name == 'demo' else None,
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
query = SimpleNamespace(variables={})
|
||||
|
||||
result = await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query)
|
||||
|
||||
assert result['activated'] is True
|
||||
assert result['skill_name'] == 'demo'
|
||||
assert result['mount_path'] == '/workspace/.skills/demo'
|
||||
assert result['activated_skill_names'] == ['demo']
|
||||
assert 'Step 1' in result['content']
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_unknown_skill_raises(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={'demo': _make_skill_data(name='demo')},
|
||||
get_skill_by_name=lambda name: None,
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='not found'):
|
||||
await loader.invoke_tool(
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
{'skill_name': 'ghost'},
|
||||
SimpleNamespace(variables={}),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_skill_scans_directory_and_creates_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo_dir = os.path.join(tmpdir, 'repo')
|
||||
os.makedirs(repo_dir)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory_async=AsyncMock(
|
||||
return_value={
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
}
|
||||
),
|
||||
create_skill=AsyncMock(
|
||||
return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir))
|
||||
),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
result = await loader.invoke_tool(
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
{'path': '/workspace/repo'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.scan_directory_async.assert_awaited_once_with(os.path.realpath(repo_dir))
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
{
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'package_root': os.path.realpath(repo_dir),
|
||||
}
|
||||
)
|
||||
assert result['registered'] is True
|
||||
assert result['skill_name'] == 'cloned-skill'
|
||||
assert result['source_path'] == '/workspace/repo'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_skill_rejects_workspace_escape(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock())
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='escapes the workspace boundary'):
|
||||
await loader.invoke_tool(
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
{'path': '/workspace/../../etc'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_skill_requires_skill_service(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap() # no skill_service attribute
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='Skill service not available'):
|
||||
await loader.invoke_tool(
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
{'path': '/workspace/foo'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_hidden_when_sandbox_backend_unavailable(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': False}}),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
assert await loader.get_tools() == []
|
||||
assert await loader.has_tool('activate') is False
|
||||
assert await loader.has_tool('register_skill') is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_exposed_when_sandbox_backend_available(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
tools = await loader.get_tools()
|
||||
|
||||
assert sorted(tool.name for tool in tools) == ['activate', 'register_skill']
|
||||
assert await loader.has_tool('activate') is True
|
||||
assert await loader.has_tool('register_skill') is True
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_visible_skill_file(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('demo instructions')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/.skills/demo/SKILL.md'},
|
||||
SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'demo instructions'
|
||||
assert result['truncated'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={})
|
||||
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace/.skills/demo',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
||||
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
|
||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
with pytest.raises(ValueError, match='Skill "demo" is not available at this path'):
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'},
|
||||
query,
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Unit tests for ToolManager.
|
||||
|
||||
Tests cover:
|
||||
- Tool schema generation for OpenAI and Anthropic
|
||||
- Tool schema generation for OpenAI/LiteLLM
|
||||
- Tool execution dispatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -52,11 +53,12 @@ class TestToolManagerSchemaGeneration:
|
||||
@pytest.fixture
|
||||
def sample_tools(self):
|
||||
"""Create sample LLMTool list for testing."""
|
||||
|
||||
def dummy_weather_func(**kwargs):
|
||||
return "weather result"
|
||||
return 'weather result'
|
||||
|
||||
def dummy_calc_func(**kwargs):
|
||||
return "calc result"
|
||||
return 'calc result'
|
||||
|
||||
tools = [
|
||||
resource_tool.LLMTool(
|
||||
@@ -65,15 +67,10 @@ class TestToolManagerSchemaGeneration:
|
||||
description='Get current weather for a location',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'location': {
|
||||
'type': 'string',
|
||||
'description': 'City name'
|
||||
}
|
||||
},
|
||||
'required': ['location']
|
||||
'properties': {'location': {'type': 'string', 'description': 'City name'}},
|
||||
'required': ['location'],
|
||||
},
|
||||
func=dummy_weather_func
|
||||
func=dummy_weather_func,
|
||||
),
|
||||
resource_tool.LLMTool(
|
||||
name='calculate',
|
||||
@@ -81,15 +78,10 @@ class TestToolManagerSchemaGeneration:
|
||||
description='Perform a calculation',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'expression': {
|
||||
'type': 'string',
|
||||
'description': 'Math expression'
|
||||
}
|
||||
},
|
||||
'required': ['expression']
|
||||
'properties': {'expression': {'type': 'string', 'description': 'Math expression'}},
|
||||
'required': ['expression'],
|
||||
},
|
||||
func=dummy_calc_func
|
||||
func=dummy_calc_func,
|
||||
),
|
||||
]
|
||||
return tools
|
||||
@@ -117,28 +109,6 @@ class TestToolManagerSchemaGeneration:
|
||||
assert tool2['type'] == 'function'
|
||||
assert tool2['function']['name'] == 'calculate'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tools_for_anthropic(self, mock_app, sample_tools):
|
||||
"""Test that generate_tools_for_anthropic produces correct schema."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
result = await manager.generate_tools_for_anthropic(sample_tools)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify first tool schema (Anthropic format)
|
||||
tool1 = result[0]
|
||||
assert tool1['name'] == 'get_weather'
|
||||
assert tool1['description'] == 'Get current weather for a location'
|
||||
assert 'input_schema' in tool1
|
||||
assert tool1['input_schema']['type'] == 'object'
|
||||
|
||||
# Verify second tool schema
|
||||
tool2 = result[1]
|
||||
assert tool2['name'] == 'calculate'
|
||||
assert 'input_schema' in tool2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tools_empty_list(self, mock_app):
|
||||
"""Test that generating tools from empty list returns empty list."""
|
||||
@@ -149,9 +119,6 @@ class TestToolManagerSchemaGeneration:
|
||||
openai_result = await manager.generate_tools_for_openai([])
|
||||
assert openai_result == []
|
||||
|
||||
anthropic_result = await manager.generate_tools_for_anthropic([])
|
||||
assert anthropic_result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_schema_fields_complete(self, mock_app, sample_tools):
|
||||
"""Test that OpenAI schema includes all required fields."""
|
||||
@@ -169,45 +136,54 @@ class TestToolManagerSchemaGeneration:
|
||||
assert 'description' in func
|
||||
assert 'parameters' in func
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_schema_fields_complete(self, mock_app, sample_tools):
|
||||
"""Test that Anthropic schema includes all required fields."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
result = await manager.generate_tools_for_anthropic(sample_tools)
|
||||
|
||||
for tool_schema in result:
|
||||
assert 'name' in tool_schema
|
||||
assert 'description' in tool_schema
|
||||
assert 'input_schema' in tool_schema
|
||||
|
||||
|
||||
class TestToolManagerExecuteFuncCall:
|
||||
"""Tests for execute_func_call method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_with_loaders(self):
|
||||
"""Create mock app with mock tool loaders."""
|
||||
"""Create mock app with mock tool loaders.
|
||||
|
||||
Returns (app, plugin_loader, mcp_loader). The native and skill loaders
|
||||
are attached directly to the app for tests that don't need to assert
|
||||
against them — they all default to ``has_tool == False`` so the
|
||||
execute_func_call probe falls through to the plugin/mcp pair.
|
||||
"""
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
|
||||
def _make_inert_loader():
|
||||
loader = Mock()
|
||||
loader.has_tool = AsyncMock(return_value=False)
|
||||
loader.invoke_tool = AsyncMock(return_value=None)
|
||||
loader.initialize = AsyncMock()
|
||||
loader.shutdown = AsyncMock()
|
||||
return loader
|
||||
|
||||
# Create mock plugin loader
|
||||
mock_plugin_loader = Mock()
|
||||
mock_plugin_loader.has_tool = AsyncMock(return_value=False)
|
||||
mock_plugin_loader = _make_inert_loader()
|
||||
mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result')
|
||||
mock_plugin_loader.initialize = AsyncMock()
|
||||
mock_plugin_loader.shutdown = AsyncMock()
|
||||
|
||||
# Create mock MCP loader
|
||||
mock_mcp_loader = Mock()
|
||||
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
|
||||
mock_mcp_loader = _make_inert_loader()
|
||||
mock_mcp_loader.invoke_tool = AsyncMock(return_value='mcp_result')
|
||||
mock_mcp_loader.initialize = AsyncMock()
|
||||
mock_mcp_loader.shutdown = AsyncMock()
|
||||
|
||||
# Stash inert native/skill loaders so the ToolManager probe order
|
||||
# (native → plugin → mcp → skill) doesn't AttributeError. Tests that
|
||||
# need to override these can replace the attributes on the manager.
|
||||
mock_app._inert_native_loader = _make_inert_loader()
|
||||
mock_app._inert_skill_loader = _make_inert_loader()
|
||||
|
||||
return mock_app, mock_plugin_loader, mock_mcp_loader
|
||||
|
||||
@staticmethod
|
||||
def _wire_loaders(manager, mock_app, plugin_loader, mcp_loader):
|
||||
"""Attach all four loaders (native + plugin + mcp + skill) to manager."""
|
||||
manager.native_tool_loader = mock_app._inert_native_loader
|
||||
manager.plugin_tool_loader = plugin_loader
|
||||
manager.mcp_tool_loader = mcp_loader
|
||||
manager.skill_tool_loader = mock_app._inert_skill_loader
|
||||
|
||||
@pytest.fixture
|
||||
def sample_query(self):
|
||||
"""Create sample query for testing."""
|
||||
@@ -215,9 +191,7 @@ class TestToolManagerExecuteFuncCall:
|
||||
return query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_calls_plugin_loader_when_has_tool(
|
||||
self, mock_app_with_loaders, sample_query
|
||||
):
|
||||
async def test_execute_calls_plugin_loader_when_has_tool(self, mock_app_with_loaders, sample_query):
|
||||
"""Test that execute_func_call uses plugin loader when tool exists there."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
@@ -225,26 +199,17 @@ class TestToolManagerExecuteFuncCall:
|
||||
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
manager.plugin_tool_loader = mock_plugin_loader
|
||||
manager.mcp_tool_loader = mock_mcp_loader
|
||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||
|
||||
result = await manager.execute_func_call(
|
||||
'test_tool',
|
||||
{'param': 'value'},
|
||||
sample_query
|
||||
)
|
||||
result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query)
|
||||
|
||||
assert result == 'plugin_result'
|
||||
mock_plugin_loader.invoke_tool.assert_called_once_with(
|
||||
'test_tool', {'param': 'value'}, sample_query
|
||||
)
|
||||
mock_plugin_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query)
|
||||
# MCP loader should not be called
|
||||
mock_mcp_loader.invoke_tool.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_calls_mcp_loader_when_plugin_not_found(
|
||||
self, mock_app_with_loaders, sample_query
|
||||
):
|
||||
async def test_execute_calls_mcp_loader_when_plugin_not_found(self, mock_app_with_loaders, sample_query):
|
||||
"""Test that execute_func_call uses MCP loader when plugin doesn't have tool."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
@@ -253,25 +218,16 @@ class TestToolManagerExecuteFuncCall:
|
||||
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
manager.plugin_tool_loader = mock_plugin_loader
|
||||
manager.mcp_tool_loader = mock_mcp_loader
|
||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||
|
||||
result = await manager.execute_func_call(
|
||||
'test_tool',
|
||||
{'param': 'value'},
|
||||
sample_query
|
||||
)
|
||||
result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query)
|
||||
|
||||
assert result == 'mcp_result'
|
||||
mock_mcp_loader.invoke_tool.assert_called_once_with(
|
||||
'test_tool', {'param': 'value'}, sample_query
|
||||
)
|
||||
mock_mcp_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_raises_when_tool_not_found(
|
||||
self, mock_app_with_loaders, sample_query
|
||||
):
|
||||
"""Test that execute_func_call raises ValueError when tool not found."""
|
||||
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
||||
"""Test that execute_func_call raises ToolNotFoundError when tool not found."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
|
||||
@@ -279,20 +235,13 @@ class TestToolManagerExecuteFuncCall:
|
||||
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
manager.plugin_tool_loader = mock_plugin_loader
|
||||
manager.mcp_tool_loader = mock_mcp_loader
|
||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||
|
||||
with pytest.raises(ValueError, match='未找到工具'):
|
||||
await manager.execute_func_call(
|
||||
'unknown_tool',
|
||||
{},
|
||||
sample_query
|
||||
)
|
||||
with pytest.raises(toolmgr.ToolNotFoundError, match='Tool not found: unknown_tool'):
|
||||
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_loader_checked_first(
|
||||
self, mock_app_with_loaders, sample_query
|
||||
):
|
||||
async def test_plugin_loader_checked_first(self, mock_app_with_loaders, sample_query):
|
||||
"""Test that plugin loader is checked before MCP loader."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
@@ -302,8 +251,7 @@ class TestToolManagerExecuteFuncCall:
|
||||
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
manager.plugin_tool_loader = mock_plugin_loader
|
||||
manager.mcp_tool_loader = mock_mcp_loader
|
||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||
|
||||
await manager.execute_func_call('test_tool', {}, sample_query)
|
||||
|
||||
@@ -317,20 +265,30 @@ class TestToolManagerShutdown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_calls_loader_shutdown(self):
|
||||
"""Test that shutdown calls shutdown on both loaders."""
|
||||
"""Test that shutdown calls shutdown on every registered loader."""
|
||||
toolmgr = get_toolmgr_module()
|
||||
|
||||
mock_app = Mock()
|
||||
mock_plugin_loader = Mock()
|
||||
mock_plugin_loader.shutdown = AsyncMock()
|
||||
mock_mcp_loader = Mock()
|
||||
mock_mcp_loader.shutdown = AsyncMock()
|
||||
|
||||
def _make_loader():
|
||||
loader = Mock()
|
||||
loader.shutdown = AsyncMock()
|
||||
return loader
|
||||
|
||||
mock_native_loader = _make_loader()
|
||||
mock_plugin_loader = _make_loader()
|
||||
mock_mcp_loader = _make_loader()
|
||||
mock_skill_loader = _make_loader()
|
||||
|
||||
manager = toolmgr.ToolManager(mock_app)
|
||||
manager.native_tool_loader = mock_native_loader
|
||||
manager.plugin_tool_loader = mock_plugin_loader
|
||||
manager.mcp_tool_loader = mock_mcp_loader
|
||||
manager.skill_tool_loader = mock_skill_loader
|
||||
|
||||
await manager.shutdown()
|
||||
|
||||
mock_native_loader.shutdown.assert_called_once()
|
||||
mock_plugin_loader.shutdown.assert_called_once()
|
||||
mock_mcp_loader.shutdown.assert_called_once()
|
||||
mock_mcp_loader.shutdown.assert_called_once()
|
||||
mock_skill_loader.shutdown.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.toolmgr import ToolManager
|
||||
|
||||
|
||||
class StubLoader:
|
||||
def __init__(
|
||||
self,
|
||||
tools: list[resource_tool.LLMTool] | None = None,
|
||||
invoke_result=None,
|
||||
catalog_source: str = 'mcp',
|
||||
catalog_source_name: str = 'fixture-server',
|
||||
):
|
||||
self._tools = tools or []
|
||||
self._invoke_result = invoke_result
|
||||
self._catalog_source = catalog_source
|
||||
self._catalog_source_name = catalog_source_name
|
||||
|
||||
async def get_tools(self, *_args, **_kwargs):
|
||||
return self._tools
|
||||
|
||||
async def get_tool_catalog(self, *_args, **_kwargs):
|
||||
return [
|
||||
{
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'human_desc': tool.human_desc,
|
||||
'parameters': tool.parameters,
|
||||
'source': self._catalog_source,
|
||||
'source_name': self._catalog_source_name,
|
||||
'source_id': self._catalog_source_name,
|
||||
}
|
||||
for tool in self._tools
|
||||
]
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return any(tool.name == name for tool in self._tools)
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query):
|
||||
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
|
||||
|
||||
async def shutdown(self):
|
||||
return None
|
||||
|
||||
|
||||
def make_tool(name: str) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=name,
|
||||
human_desc=name,
|
||||
description=name,
|
||||
parameters={'type': 'object', 'properties': {}},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_omits_skill_authoring_tools_by_default():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
tools = await manager.get_all_tools()
|
||||
|
||||
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_includes_skill_authoring_tools_when_requested():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
tools = await manager.get_all_tools(include_skill_authoring=True)
|
||||
|
||||
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_catalog_labels_tool_sources():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader(
|
||||
[make_tool('plugin_tool')],
|
||||
catalog_source='plugin',
|
||||
catalog_source_name='fixture-plugin',
|
||||
)
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
catalog = await manager.get_tool_catalog(include_skill_authoring=True)
|
||||
|
||||
assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [
|
||||
('exec', 'builtin', 'LangBot'),
|
||||
('activate', 'skill', 'LangBot'),
|
||||
('plugin_tool', 'plugin', 'fixture-plugin'),
|
||||
('mcp_tool', 'mcp', 'fixture-server'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_routes_native_tool_calls():
|
||||
app = SimpleNamespace()
|
||||
manager = ToolManager(app)
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'})
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
|
||||
|
||||
assert result == {'backend': 'fake'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_hides_tools_when_box_unavailable():
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
|
||||
|
||||
assert await loader.get_tools() == []
|
||||
for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'):
|
||||
assert await loader.has_tool(tool_name) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_exposes_all_tools_when_box_available():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
await loader.initialize()
|
||||
|
||||
tools = await loader.get_tools()
|
||||
|
||||
assert [tool.name for tool in tools] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
|
||||
for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'):
|
||||
assert await loader.has_tool(tool_name) is True
|
||||
|
||||
|
||||
# ── read/write/edit file tool tests ─────────────────────────────
|
||||
|
||||
|
||||
def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
|
||||
logger = Mock()
|
||||
box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
|
||||
ap = SimpleNamespace(box_service=box_service, logger=logger)
|
||||
return NativeToolLoader(ap), logger
|
||||
|
||||
|
||||
def _make_query() -> Mock:
|
||||
q = Mock()
|
||||
q.query_id = 'test-query-1'
|
||||
return q
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'hello.txt'), 'w') as f:
|
||||
f.write('hello world')
|
||||
|
||||
result = await loader.invoke_tool('read', {'path': '/workspace/hello.txt'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'hello world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_nonexistent_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool('read', {'path': '/workspace/no_such.txt'}, _make_query())
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_directory():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
os.makedirs(os.path.join(tmpdir, 'subdir'))
|
||||
with open(os.path.join(tmpdir, 'a.txt'), 'w') as f:
|
||||
f.write('a')
|
||||
|
||||
result = await loader.invoke_tool('read', {'path': '/workspace'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['is_directory'] is True
|
||||
assert 'a.txt' in result['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_creates_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'write', {'path': '/workspace/new.txt', 'content': 'new content'}, _make_query()
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'new.txt')) as f:
|
||||
assert f.read() == 'new content'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_creates_subdirectories():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'write', {'path': '/workspace/sub/deep/file.txt', 'content': 'nested'}, _make_query()
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'sub', 'deep', 'file.txt')) as f:
|
||||
assert f.read() == 'nested'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_binary_file_as_base64_chunk():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'wb') as f:
|
||||
f.write(b'\x00\x01\x02\x03\x04')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'encoding': 'base64',
|
||||
'byte_offset': 1,
|
||||
'max_bytes': 2,
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == base64.b64encode(b'\x01\x02').decode('ascii')
|
||||
assert result['encoding'] == 'base64'
|
||||
assert result['byte_offset'] == 1
|
||||
assert result['length'] == 2
|
||||
assert result['size_bytes'] == 5
|
||||
assert result['has_more'] is True
|
||||
assert result['next_byte_offset'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_file_append():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
first = base64.b64encode(b'\x00\x01').decode('ascii')
|
||||
second = base64.b64encode(b'\x02\x03').decode('ascii')
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': first, 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'content': second,
|
||||
'encoding': 'base64',
|
||||
'mode': 'append',
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'rb') as f:
|
||||
assert f.read() == b'\x00\x01\x02\x03'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_rejects_invalid_content():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': 'not base64!', 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'invalid base64' in result['error']
|
||||
assert not os.path.exists(os.path.join(tmpdir, 'blob.bin'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_replaces_unique_string():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'code.py'), 'w') as f:
|
||||
f.write('def foo():\n return 1\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'edit',
|
||||
{'path': '/workspace/code.py', 'old_string': 'return 1', 'new_string': 'return 42'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'code.py')) as f:
|
||||
assert f.read() == 'def foo():\n return 42\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_rejects_ambiguous_match():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'dup.txt'), 'w') as f:
|
||||
f.write('aaa\naaa\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'edit',
|
||||
{'path': '/workspace/dup.txt', 'old_string': 'aaa', 'new_string': 'bbb'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert '2' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_rejects_missing_string():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'x.txt'), 'w') as f:
|
||||
f.write('hello')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'edit',
|
||||
{'path': '/workspace/x.txt', 'old_string': 'nope', 'new_string': 'yes'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_escape_blocked():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
with pytest.raises(ValueError, match='escapes'):
|
||||
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_availability_helper_handles_unavailable_and_errors():
|
||||
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
|
||||
|
||||
assert await is_box_backend_available(SimpleNamespace()) is False
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=SimpleNamespace(available=False))) is False
|
||||
|
||||
unavailable_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': False}}),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=unavailable_backend)) is False
|
||||
|
||||
failing_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(side_effect=RuntimeError('box unavailable')),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=failing_backend)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_supports_offset_limit_and_truncation_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'lines.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('one\ntwo\nthree\nfour\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/lines.txt', 'offset': 2, 'limit': 2},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'ok': True,
|
||||
'content': 'two\nthree',
|
||||
'truncated': True,
|
||||
'truncated_by': 'lines',
|
||||
'start_line': 2,
|
||||
'end_line': 3,
|
||||
'next_offset': 4,
|
||||
'max_lines': 2,
|
||||
'max_bytes': 50 * 1024,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_handles_line_larger_than_byte_limit():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'long-line.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('abcdef\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/long-line.txt', 'max_bytes': 3},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
assert result['next_offset'] == 1
|
||||
assert 'exceeds the 3B read limit' in result['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_result_is_capped_and_exposes_preview_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'stdout': 'a' * 60000,
|
||||
'stderr': 'b' * 60000,
|
||||
'exit_code': 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
|
||||
result = await loader.invoke_tool('exec', {'command': 'python -V'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert len(result['stdout'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['stderr'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['preview'].encode('utf-8')) == 50 * 1024
|
||||
assert result['stdout_truncated'] is True
|
||||
assert result['stderr_truncated'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_caps_match_count_and_returns_preview():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
for index in range(105):
|
||||
with open(os.path.join(tmpdir, f'file-{index:03d}.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write(str(index))
|
||||
|
||||
result = await loader.invoke_tool('glob', {'path': '/workspace', 'pattern': '*.txt'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['total'] == 105
|
||||
assert len(result['matches']) == 100
|
||||
assert result['preview'] == '\n'.join(result['matches'])
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'matches'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'data.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('needle ' + ('x' * 600) + '\n')
|
||||
|
||||
invalid = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': '['}, _make_query())
|
||||
result = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': 'needle'}, _make_query())
|
||||
|
||||
assert invalid['ok'] is False
|
||||
assert 'Invalid regex' in invalid['error']
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'line'
|
||||
assert result['matches'][0]['file'] == '/workspace/data.txt'
|
||||
assert result['matches'][0]['content'].endswith('... [truncated]')
|
||||
@@ -3,6 +3,7 @@
|
||||
Tests cover:
|
||||
- _to_i18n_name() static method
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
@@ -60,4 +61,4 @@ class TestToI18nName:
|
||||
kbmgr = get_kbmgr_module()
|
||||
input_dict = {'en_US': 'English', 'extra_key': 'extra_value'}
|
||||
result = kbmgr.RAGManager._to_i18n_name(input_dict)
|
||||
assert result == {'en_US': 'English', 'extra_key': 'extra_value'}
|
||||
assert result == {'en_US': 'English', 'extra_key': 'extra_value'}
|
||||
|
||||
@@ -6,6 +6,7 @@ Tests cover:
|
||||
- Knowledge engine enrichment
|
||||
- KB loading and removal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -101,13 +102,9 @@ class TestRAGManagerCreateKnowledgeBase:
|
||||
rag_module = get_rag_module()
|
||||
mock_app = create_mock_app()
|
||||
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/engine'}]
|
||||
)
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}])
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
|
||||
side_effect=Exception('Plugin error')
|
||||
)
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin error'))
|
||||
|
||||
manager = rag_module.RAGManager(mock_app)
|
||||
|
||||
@@ -128,9 +125,7 @@ class TestRAGManagerCreateKnowledgeBase:
|
||||
rag_module = get_rag_module()
|
||||
mock_app = create_mock_app()
|
||||
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/engine'}]
|
||||
)
|
||||
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}])
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
|
||||
|
||||
@@ -206,9 +201,7 @@ class TestRuntimeKnowledgeBaseOnKBCreate:
|
||||
mock_app = create_mock_app()
|
||||
mock_kb = create_mock_kb_entity()
|
||||
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
|
||||
side_effect=Exception('Plugin failed')
|
||||
)
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin failed'))
|
||||
|
||||
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
|
||||
|
||||
@@ -245,9 +238,7 @@ class TestRuntimeKnowledgeBaseIngestDocument:
|
||||
mock_app = create_mock_app()
|
||||
mock_kb = create_mock_kb_entity()
|
||||
|
||||
mock_app.plugin_connector.call_rag_ingest = AsyncMock(
|
||||
return_value={'status': 'success'}
|
||||
)
|
||||
mock_app.plugin_connector.call_rag_ingest = AsyncMock(return_value={'status': 'success'})
|
||||
|
||||
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
|
||||
|
||||
@@ -304,14 +295,10 @@ class TestRAGManagerLoadKnowledgeBasesFromDB:
|
||||
# KB that will cause initialize to fail
|
||||
mock_kb = create_mock_kb_entity()
|
||||
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(all=Mock(return_value=[mock_kb]))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb])))
|
||||
|
||||
# Make initialize fail by having plugin_connector throw error
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
|
||||
side_effect=Exception('Init failed')
|
||||
)
|
||||
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Init failed'))
|
||||
|
||||
manager = rag_module.RAGManager(mock_app)
|
||||
# Should not raise - errors are caught
|
||||
@@ -411,9 +398,7 @@ class TestRuntimeKnowledgeBaseRetrieve:
|
||||
mock_kb = create_mock_kb_entity()
|
||||
mock_kb.retrieval_settings = {}
|
||||
|
||||
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
|
||||
return_value={'results': []}
|
||||
)
|
||||
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(return_value={'results': []})
|
||||
|
||||
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
|
||||
|
||||
@@ -682,9 +667,7 @@ class TestRAGManagerGetAllDetails:
|
||||
"""Test returns empty list when no knowledge bases."""
|
||||
rag_module = get_rag_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(all=Mock(return_value=[]))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
|
||||
|
||||
manager = rag_module.RAGManager(mock_app)
|
||||
result = await manager.get_all_knowledge_base_details()
|
||||
@@ -699,9 +682,7 @@ class TestRAGManagerGetAllDetails:
|
||||
|
||||
# Mock DB result
|
||||
mock_kb_row = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(all=Mock(return_value=[mock_kb_row]))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb_row])))
|
||||
mock_app.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
|
||||
)
|
||||
@@ -724,9 +705,7 @@ class TestRAGManagerGetDetails:
|
||||
"""Test returns None when KB doesn't exist."""
|
||||
rag_module = get_rag_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(first=Mock(return_value=None))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
|
||||
manager = rag_module.RAGManager(mock_app)
|
||||
result = await manager.get_knowledge_base_details('nonexistent')
|
||||
@@ -740,9 +719,7 @@ class TestRAGManagerGetDetails:
|
||||
mock_app = create_mock_app()
|
||||
|
||||
mock_kb_row = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(first=Mock(return_value=mock_kb_row))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=mock_kb_row)))
|
||||
mock_app.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
|
||||
)
|
||||
@@ -791,4 +768,4 @@ class TestRAGManagerLoadKnowledgeBase:
|
||||
|
||||
await manager.load_knowledge_base(kb_dict)
|
||||
|
||||
assert 'kb-uuid' in manager.knowledge_bases
|
||||
assert 'kb-uuid' in manager.knowledge_bases
|
||||
|
||||
@@ -121,10 +121,12 @@ class TestRAGRuntimeServiceVectorSearch:
|
||||
"""Create mock app."""
|
||||
mock_app = MagicMock()
|
||||
mock_app.vector_db_mgr = MagicMock()
|
||||
mock_app.vector_db_mgr.search = AsyncMock(return_value=[
|
||||
{'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}},
|
||||
{'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}},
|
||||
])
|
||||
mock_app.vector_db_mgr.search = AsyncMock(
|
||||
return_value=[
|
||||
{'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}},
|
||||
{'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}},
|
||||
]
|
||||
)
|
||||
return mock_app
|
||||
|
||||
def _make_rag_import_mocks(self):
|
||||
@@ -301,10 +303,7 @@ class TestRAGRuntimeServiceVectorList:
|
||||
mock_app = MagicMock()
|
||||
mock_app.vector_db_mgr = MagicMock()
|
||||
mock_app.vector_db_mgr.list_by_filter = AsyncMock(
|
||||
return_value=(
|
||||
[{'id': 'id1', 'metadata': {'file_id': 'abc'}}],
|
||||
10
|
||||
)
|
||||
return_value=([{'id': 'id1', 'metadata': {'file_id': 'abc'}}], 10)
|
||||
)
|
||||
return mock_app
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
|
||||
@pytest.fixture
|
||||
def storage_provider(tmp_path):
|
||||
"""Create a LocalStorageProvider with a temporary storage path."""
|
||||
storage_path = str(tmp_path / "storage")
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
storage_path = str(tmp_path / 'storage')
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
mock_app = Mock()
|
||||
provider = LocalStorageProvider(mock_app)
|
||||
yield provider, storage_path
|
||||
@@ -35,15 +35,15 @@ class TestPathTraversalPrevention:
|
||||
async def test_absolute_path_save_rejected(self, storage_provider, tmp_path):
|
||||
"""Saving with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
target_file = str(tmp_path / "pwned.txt")
|
||||
target_file = str(tmp_path / 'pwned.txt')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.save(target_file, b"malicious content")
|
||||
await provider.save(target_file, b'malicious content')
|
||||
|
||||
# The file must NOT exist outside the storage directory
|
||||
assert not os.path.exists(target_file), (
|
||||
f"Path traversal succeeded: file was written outside storage to {target_file}"
|
||||
f'Path traversal succeeded: file was written outside storage to {target_file}'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -52,32 +52,28 @@ class TestPathTraversalPrevention:
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
# Create a file outside the storage directory
|
||||
target_file = str(tmp_path / "secret.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"secret data")
|
||||
target_file = str(tmp_path / 'secret.txt')
|
||||
with open(target_file, 'wb') as f:
|
||||
f.write(b'secret data')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(target_file)
|
||||
assert data != b"secret data", (
|
||||
"Path traversal succeeded: read file outside storage"
|
||||
)
|
||||
assert data != b'secret data', 'Path traversal succeeded: read file outside storage'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path):
|
||||
"""Exists check with an absolute path key must be blocked or return False."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "check_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"data")
|
||||
target_file = str(tmp_path / 'check_me.txt')
|
||||
with open(target_file, 'wb') as f:
|
||||
f.write(b'data')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
try:
|
||||
result = await provider.exists(target_file)
|
||||
assert result is False, (
|
||||
"Path traversal succeeded: exists() returned True for file outside storage"
|
||||
)
|
||||
assert result is False, 'Path traversal succeeded: exists() returned True for file outside storage'
|
||||
except (ValueError, PermissionError):
|
||||
pass # Expected
|
||||
|
||||
@@ -86,28 +82,26 @@ class TestPathTraversalPrevention:
|
||||
"""Deleting with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "do_not_delete.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"important data")
|
||||
target_file = str(tmp_path / 'do_not_delete.txt')
|
||||
with open(target_file, 'wb') as f:
|
||||
f.write(b'important data')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.delete(target_file)
|
||||
|
||||
assert os.path.exists(target_file), (
|
||||
"Path traversal succeeded: file outside storage was deleted"
|
||||
)
|
||||
assert os.path.exists(target_file), 'Path traversal succeeded: file outside storage was deleted'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_size_rejected(self, storage_provider, tmp_path):
|
||||
"""Size check with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "measure_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"some data")
|
||||
target_file = str(tmp_path / 'measure_me.txt')
|
||||
with open(target_file, 'wb') as f:
|
||||
f.write(b'some data')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.size(target_file)
|
||||
|
||||
@@ -116,41 +110,39 @@ class TestPathTraversalPrevention:
|
||||
"""Relative path traversal with '..' must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "above_storage.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"above storage secret")
|
||||
target_file = str(tmp_path / 'above_storage.txt')
|
||||
with open(target_file, 'wb') as f:
|
||||
f.write(b'above storage secret')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
relative_key = os.path.join("..", "above_storage.txt")
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
relative_key = os.path.join('..', 'above_storage.txt')
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(relative_key)
|
||||
assert data != b"above storage secret"
|
||||
assert data != b'above storage secret'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path):
|
||||
"""delete_dir_recursive with traversal path must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir = tmp_path / 'outside_dir'
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "file.txt").write_text("important")
|
||||
(outside_dir / 'file.txt').write_text('important')
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.delete_dir_recursive(str(outside_dir))
|
||||
|
||||
assert outside_dir.exists(), (
|
||||
"Path traversal succeeded: directory outside storage was deleted"
|
||||
)
|
||||
assert outside_dir.exists(), 'Path traversal succeeded: directory outside storage was deleted'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legitimate_key_works(self, storage_provider):
|
||||
"""Normal keys without traversal must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "test_image_abc123.png"
|
||||
content = b"PNG image data"
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
key = 'test_image_abc123.png'
|
||||
content = b'PNG image data'
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
@@ -166,9 +158,9 @@ class TestPathTraversalPrevention:
|
||||
"""Keys with legitimate subdirectories must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "bot_log_images/img_001.png"
|
||||
content = b"PNG image data"
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
key = 'bot_log_images/img_001.png'
|
||||
content = b'PNG image data'
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
@@ -181,33 +173,33 @@ class TestPathTraversalPrevention:
|
||||
"""delete_dir_recursive should handle non-existing directories gracefully."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
# Try to delete a non-existing directory - should not raise
|
||||
await provider.delete_dir_recursive("nonexistent_dir")
|
||||
await provider.delete_dir_recursive('nonexistent_dir')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dir_recursive_with_files(self, storage_provider):
|
||||
"""delete_dir_recursive should delete directory with files inside."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
|
||||
# Create a directory with files
|
||||
key1 = "test_dir/file1.txt"
|
||||
key2 = "test_dir/file2.txt"
|
||||
await provider.save(key1, b"content1")
|
||||
await provider.save(key2, b"content2")
|
||||
key1 = 'test_dir/file1.txt'
|
||||
key2 = 'test_dir/file2.txt'
|
||||
await provider.save(key1, b'content1')
|
||||
await provider.save(key2, b'content2')
|
||||
|
||||
# Verify files exist
|
||||
assert await provider.exists(key1)
|
||||
assert await provider.exists(key2)
|
||||
|
||||
# Delete directory recursively
|
||||
await provider.delete_dir_recursive("test_dir")
|
||||
await provider.delete_dir_recursive('test_dir')
|
||||
|
||||
# Verify files no longer exist
|
||||
assert not await provider.exists(key1)
|
||||
assert not await provider.exists(key2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -8,6 +8,7 @@ Tests cover:
|
||||
|
||||
Uses moto library to mock AWS S3 service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -44,8 +45,10 @@ def mock_app_with_s3_config():
|
||||
def s3_mock():
|
||||
"""Set up moto S3 mock context."""
|
||||
from moto import mock_aws
|
||||
|
||||
with mock_aws():
|
||||
import boto3
|
||||
|
||||
# Create bucket for tests that need pre-existing bucket
|
||||
s3 = boto3.client('s3', region_name='us-east-1')
|
||||
yield s3
|
||||
@@ -325,4 +328,4 @@ class TestS3StorageProviderErrorHandling:
|
||||
await provider.initialize()
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await provider.size('nonexistent.txt')
|
||||
await provider.size('nonexistent.txt')
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestStorageMgr:
|
||||
|
||||
storage_mgr = StorageMgr(mock_app)
|
||||
|
||||
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
|
||||
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
|
||||
await storage_mgr.initialize()
|
||||
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
|
||||
mock_app.logger.info.assert_called()
|
||||
@@ -41,12 +41,12 @@ class TestStorageMgr:
|
||||
"""Should use local storage when explicitly configured."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {"storage": {"use": "local"}}
|
||||
mock_app.instance_config.data = {'storage': {'use': 'local'}}
|
||||
mock_app.logger = Mock()
|
||||
|
||||
storage_mgr = StorageMgr(mock_app)
|
||||
|
||||
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
|
||||
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
|
||||
await storage_mgr.initialize()
|
||||
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
|
||||
|
||||
@@ -55,14 +55,12 @@ class TestStorageMgr:
|
||||
"""Should use S3 storage when configured."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
"storage": {"use": "s3", "s3": {"endpoint_url": "https://s3.amazonaws.com"}}
|
||||
}
|
||||
mock_app.instance_config.data = {'storage': {'use': 's3', 's3': {'endpoint_url': 'https://s3.amazonaws.com'}}}
|
||||
mock_app.logger = Mock()
|
||||
|
||||
storage_mgr = StorageMgr(mock_app)
|
||||
|
||||
with patch.object(S3StorageProvider, "initialize", new_callable=AsyncMock):
|
||||
with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock):
|
||||
await storage_mgr.initialize()
|
||||
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
|
||||
|
||||
@@ -71,12 +69,12 @@ class TestStorageMgr:
|
||||
"""Should default to local storage for invalid storage type."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {"storage": {"use": "invalid_type"}}
|
||||
mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}}
|
||||
mock_app.logger = Mock()
|
||||
|
||||
storage_mgr = StorageMgr(mock_app)
|
||||
|
||||
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
|
||||
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
|
||||
await storage_mgr.initialize()
|
||||
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
|
||||
|
||||
@@ -90,9 +88,7 @@ class TestStorageMgr:
|
||||
|
||||
storage_mgr = StorageMgr(mock_app)
|
||||
|
||||
with patch.object(
|
||||
LocalStorageProvider, "initialize", new_callable=AsyncMock
|
||||
) as mock_init:
|
||||
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
|
||||
await storage_mgr.initialize()
|
||||
mock_init.assert_called_once()
|
||||
|
||||
@@ -105,8 +101,8 @@ class TestStorageProviderBase:
|
||||
mock_app = Mock()
|
||||
|
||||
# Use LocalStorageProvider as concrete implementation
|
||||
with patch("os.path.exists", return_value=True):
|
||||
with patch("os.makedirs"):
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch('os.makedirs'):
|
||||
provider = LocalStorageProvider(mock_app)
|
||||
assert provider.ap == mock_app
|
||||
|
||||
@@ -115,12 +111,12 @@ class TestStorageProviderBase:
|
||||
"""Provider base initialize should be callable and do nothing."""
|
||||
mock_app = Mock()
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
with patch("os.makedirs"):
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch('os.makedirs'):
|
||||
provider = LocalStorageProvider(mock_app)
|
||||
# Initialize should not raise
|
||||
await provider.initialize()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
- Survey response submission
|
||||
- Survey dismissal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -127,9 +128,7 @@ class TestLoadTriggeredEvents:
|
||||
"""Test that empty set is used when no events stored."""
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(first=Mock(return_value=None))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager._load_triggered_events()
|
||||
@@ -219,9 +218,7 @@ class TestTriggerEvent:
|
||||
"""Test that new event is added and saved."""
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(first=Mock(return_value=None))
|
||||
)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
manager._space_url = 'https://space.example.com'
|
||||
@@ -231,6 +228,104 @@ class TestTriggerEvent:
|
||||
assert 'new_event' in manager._triggered_events
|
||||
|
||||
|
||||
class TestRecordBotResponseSuccess:
|
||||
"""Tests for the bot_response_success_100 milestone counter."""
|
||||
|
||||
def _make_manager(self, survey_module, mock_app):
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
manager._space_url = 'https://space.example.com'
|
||||
# No existing metadata rows: select returns no row
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
return manager
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increments_and_persists_count(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
manager = self._make_manager(survey_module, mock_app)
|
||||
|
||||
await manager.record_bot_response_success()
|
||||
|
||||
assert manager._bot_response_count == 1
|
||||
# select + insert for the count key
|
||||
assert mock_app.persistence_mgr.execute_async.call_count >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fires_milestone_event_at_threshold(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
manager = self._make_manager(survey_module, mock_app)
|
||||
manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE - 1
|
||||
|
||||
await manager.record_bot_response_success()
|
||||
|
||||
assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE
|
||||
assert survey_module.BOT_RESPONSE_MILESTONE_EVENT in manager._triggered_events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_fire_below_threshold(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
manager = self._make_manager(survey_module, mock_app)
|
||||
manager._bot_response_count = 5
|
||||
|
||||
await manager.record_bot_response_success()
|
||||
|
||||
assert survey_module.BOT_RESPONSE_MILESTONE_EVENT not in manager._triggered_events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stops_counting_after_milestone_triggered(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
manager = self._make_manager(survey_module, mock_app)
|
||||
manager._triggered_events.add(survey_module.BOT_RESPONSE_MILESTONE_EVENT)
|
||||
manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE
|
||||
|
||||
await manager.record_bot_response_success()
|
||||
|
||||
# No persistence write, count unchanged
|
||||
mock_app.persistence_mgr.execute_async.assert_not_called()
|
||||
assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_space_not_configured(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
manager = self._make_manager(survey_module, mock_app)
|
||||
manager._space_url = ''
|
||||
|
||||
await manager.record_bot_response_success()
|
||||
|
||||
assert manager._bot_response_count == 0
|
||||
mock_app.persistence_mgr.execute_async.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_loaded_on_initialize(self):
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
|
||||
count_row = Mock()
|
||||
count_row.value = '42'
|
||||
|
||||
def execute_side_effect(stmt):
|
||||
result = Mock()
|
||||
# Both _load_triggered_events and _load_bot_response_count select
|
||||
# from Metadata; return the count row only for the count key.
|
||||
stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True}))
|
||||
if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str:
|
||||
result.first.return_value = (count_row,)
|
||||
else:
|
||||
result.first.return_value = None
|
||||
return result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect)
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager.initialize()
|
||||
|
||||
assert manager._bot_response_count == 42
|
||||
|
||||
|
||||
class TestPendingSurvey:
|
||||
"""Tests for get_pending_survey and clear_pending_survey."""
|
||||
|
||||
@@ -296,14 +391,19 @@ class TestSubmitResponse:
|
||||
|
||||
# Mock successful HTTP response
|
||||
import httpx
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock(
|
||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||
__aexit__=AsyncMock(return_value=None)
|
||||
))
|
||||
m.setattr(
|
||||
httpx,
|
||||
'AsyncClient',
|
||||
lambda **kwargs: MagicMock(
|
||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||
__aexit__=AsyncMock(return_value=None),
|
||||
),
|
||||
)
|
||||
result = await manager.submit_response('survey123', {'q1': 'answer1'})
|
||||
|
||||
assert result is True
|
||||
@@ -338,15 +438,20 @@ class TestDismissSurvey:
|
||||
|
||||
# Mock successful HTTP response
|
||||
import httpx
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock(
|
||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||
__aexit__=AsyncMock(return_value=None)
|
||||
))
|
||||
m.setattr(
|
||||
httpx,
|
||||
'AsyncClient',
|
||||
lambda **kwargs: MagicMock(
|
||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||
__aexit__=AsyncMock(return_value=None),
|
||||
),
|
||||
)
|
||||
result = await manager.dismiss_survey('survey123')
|
||||
|
||||
assert result is True
|
||||
assert manager._pending_survey is None
|
||||
assert manager._pending_survey is None
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for telemetry feature counters (pkg/telemetry/features.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
def get_features_module():
|
||||
return import_module('langbot.pkg.telemetry.features')
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self):
|
||||
self.variables = {}
|
||||
|
||||
|
||||
class TestIncrement:
|
||||
def test_increment_nested_counter(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.increment(q, 'tool_calls', 'native')
|
||||
features.increment(q, 'tool_calls', 'native')
|
||||
features.increment(q, 'tool_calls', 'mcp')
|
||||
assert q.variables[features.FEATURES_KEY]['tool_calls'] == {'native': 2, 'mcp': 1}
|
||||
|
||||
def test_increment_flat_counter(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.increment(q, 'something')
|
||||
features.increment(q, 'something', amount=2)
|
||||
assert q.variables[features.FEATURES_KEY]['something'] == 3
|
||||
|
||||
def test_increment_never_raises_on_broken_query(self):
|
||||
features = get_features_module()
|
||||
|
||||
class Broken:
|
||||
@property
|
||||
def variables(self):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
# Must not raise
|
||||
features.increment(Broken(), 'tool_calls', 'native')
|
||||
|
||||
def test_set_value(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.set_value(q, 'tool_call_rounds', 5)
|
||||
assert q.variables[features.FEATURES_KEY]['tool_call_rounds'] == 5
|
||||
|
||||
|
||||
class TestCollectFeatures:
|
||||
def test_collect_empty(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
assert features.collect_features(q) == {}
|
||||
|
||||
def test_collect_combines_counters_and_snapshots(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.increment(q, 'sandbox', 'execs')
|
||||
features.set_value(q, 'kb', {'kb_count': 2, 'engine_plugins': ['builtin'], 'retrieved_entries': 7})
|
||||
q.variables['_activated_skills'] = {'pdf-tools': {}, 'a-skill': {}}
|
||||
q.variables['_pipeline_bound_mcp_servers'] = ['srv1', 'srv2']
|
||||
|
||||
result = features.collect_features(q)
|
||||
assert result['sandbox'] == {'execs': 1}
|
||||
assert result['kb']['kb_count'] == 2
|
||||
assert result['activated_skills'] == ['a-skill', 'pdf-tools'] # sorted
|
||||
assert result['mcp_servers'] == ['srv1', 'srv2']
|
||||
|
||||
def test_collect_omits_mcp_when_all_enabled(self):
|
||||
"""None means 'all enabled' and is not reported."""
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
q.variables['_pipeline_bound_mcp_servers'] = None
|
||||
assert 'mcp_servers' not in features.collect_features(q)
|
||||
|
||||
def test_collect_drops_non_json_serializable(self):
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.set_value(q, 'good', 1)
|
||||
features.set_value(q, 'bad', object())
|
||||
result = features.collect_features(q)
|
||||
assert result == {'good': 1}
|
||||
|
||||
def test_collect_is_json_serializable(self):
|
||||
import json
|
||||
|
||||
features = get_features_module()
|
||||
q = FakeQuery()
|
||||
features.increment(q, 'tool_calls', 'skill')
|
||||
json.dumps(features.collect_features(q))
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Unit tests for telemetry heartbeat payload (pkg/telemetry/heartbeat.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
def get_heartbeat_module():
|
||||
return import_module('langbot.pkg.telemetry.heartbeat')
|
||||
|
||||
|
||||
def make_app():
|
||||
ap = Mock()
|
||||
ap.instance_config = Mock()
|
||||
ap.instance_config.data = {
|
||||
'database': {'use': 'postgresql'},
|
||||
'vdb': {'use': 'chroma'},
|
||||
'box': {'enabled': True, 'backend': 'nsjail'},
|
||||
}
|
||||
|
||||
# persistence counts
|
||||
result = Mock()
|
||||
result.scalar.return_value = 3
|
||||
ap.persistence_mgr = Mock()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=result)
|
||||
|
||||
# box service
|
||||
ap.box_service = Mock()
|
||||
ap.box_service.enabled = True
|
||||
ap.box_service.available = False
|
||||
ap.box_service.shares_filesystem_with_box = False
|
||||
|
||||
# platform manager with one enabled bot
|
||||
bot = Mock()
|
||||
bot.enable = True
|
||||
bot.adapter = Mock()
|
||||
bot.adapter.__class__.__name__ = 'TelegramAdapter'
|
||||
ap.platform_mgr = Mock()
|
||||
ap.platform_mgr.bots = [bot]
|
||||
|
||||
# plugin connector
|
||||
ap.plugin_connector = Mock()
|
||||
ap.plugin_connector.list_plugins = AsyncMock(return_value=[{}, {}])
|
||||
|
||||
# skills
|
||||
ap.skill_mgr = Mock()
|
||||
ap.skill_mgr.skills = {'a': {}, 'b': {}, 'c': {}}
|
||||
|
||||
return ap
|
||||
|
||||
|
||||
class TestBuildHeartbeatPayload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_shape(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
|
||||
assert payload['event_type'] == 'instance_heartbeat'
|
||||
assert payload['query_id'] == ''
|
||||
assert 'instance_create_ts' in payload
|
||||
assert 'timestamp' in payload
|
||||
f = payload['features']
|
||||
assert f['database'] == 'postgresql'
|
||||
assert f['vdb'] == 'chroma'
|
||||
assert f['box'] == {
|
||||
'enabled': True,
|
||||
'available': False,
|
||||
'backend': 'nsjail',
|
||||
'shares_fs': False,
|
||||
}
|
||||
assert f['adapters'] == ['TelegramAdapter']
|
||||
assert f['bot_count'] == 1
|
||||
assert f['plugin_count'] == 2
|
||||
assert f['skill_count'] == 3
|
||||
assert f['pipeline_count'] == 3
|
||||
assert f['mcp_server_count'] == 3
|
||||
assert f['knowledge_base_count'] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_is_json_serializable(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
json.dumps(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_failure_yields_minus_one(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
assert payload['features']['pipeline_count'] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_content_fields(self):
|
||||
"""The heartbeat must never carry message content / credentials keys."""
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
flat = json.dumps(payload).lower()
|
||||
for forbidden in ('api_key', 'password', 'token', 'message_content'):
|
||||
assert forbidden not in flat
|
||||
@@ -8,6 +8,7 @@ Tests cover:
|
||||
- HTTP request success/failure scenarios
|
||||
- Source code bug: send_tasks should be instance variable
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -38,6 +39,7 @@ class TestTelemetryManagerInit:
|
||||
manager = telemetry.TelemetryManager(mock_app)
|
||||
assert manager.telemetry_config == {}
|
||||
|
||||
|
||||
class TestTelemetryManagerInitialize:
|
||||
"""Tests for initialize() method."""
|
||||
|
||||
@@ -218,7 +220,7 @@ class TestPayloadSanitization:
|
||||
|
||||
# All null string fields should be empty strings
|
||||
for field in ['adapter', 'runner', 'runner_category', 'model_name', 'version', 'edition', 'error', 'timestamp']:
|
||||
assert result[field] == '', f"Field {field} should be empty string, got {result[field]}"
|
||||
assert result[field] == '', f'Field {field} should be empty string, got {result[field]}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sanitize_string_fields_preserve_values(self):
|
||||
@@ -418,9 +420,7 @@ class TestHTTPScenarios:
|
||||
manager.telemetry_config = {'url': 'https://example.com'}
|
||||
|
||||
mock_response = Mock(
|
||||
status_code=200,
|
||||
text='{"code": 0, "msg": "success"}',
|
||||
json=Mock(return_value={'code': 0, 'msg': 'success'})
|
||||
status_code=200, text='{"code": 0, "msg": "success"}', json=Mock(return_value={'code': 0, 'msg': 'success'})
|
||||
)
|
||||
|
||||
mock_client = Mock()
|
||||
@@ -448,9 +448,7 @@ class TestHTTPScenarios:
|
||||
manager.telemetry_config = {'url': 'https://example.com'}
|
||||
|
||||
mock_response = Mock(
|
||||
status_code=500,
|
||||
text='Internal Server Error',
|
||||
json=Mock(return_value={'code': 500, 'msg': 'error'})
|
||||
status_code=500, text='Internal Server Error', json=Mock(return_value={'code': 500, 'msg': 'error'})
|
||||
)
|
||||
|
||||
mock_client = Mock()
|
||||
@@ -478,7 +476,7 @@ class TestHTTPScenarios:
|
||||
mock_response = Mock(
|
||||
status_code=200,
|
||||
text='{"code": 400, "msg": "Bad Request"}',
|
||||
json=Mock(return_value={'code': 400, 'msg': 'Bad Request'})
|
||||
json=Mock(return_value={'code': 400, 'msg': 'Bad Request'}),
|
||||
)
|
||||
|
||||
mock_client = Mock()
|
||||
@@ -493,7 +491,7 @@ class TestHTTPScenarios:
|
||||
assert mock_app.logger.warning.call_count >= 1
|
||||
# Check that one of the calls contains application error info
|
||||
all_warnings = [call[0][0] for call in mock_app.logger.warning.call_args_list]
|
||||
assert any('400' in w for w in all_warnings), f"No warning contained error code 400: {all_warnings}"
|
||||
assert any('400' in w for w in all_warnings), f'No warning contained error code 400: {all_warnings}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_timeout_logs_warning(self):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
|
||||
def test_get_data_root_uses_source_root_in_repo_checkout():
|
||||
data_root = Path(paths.get_data_root())
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_root == repo_root / 'data'
|
||||
|
||||
|
||||
def test_get_data_path_joins_under_data_root():
|
||||
data_path = Path(paths.get_data_path('skills', 'demo-skill'))
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_path == repo_root / 'data' / 'skills' / 'demo-skill'
|
||||
|
||||
|
||||
def test_get_data_root_honors_env_override(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'custom-data'))
|
||||
|
||||
assert Path(paths.get_data_root()) == (tmp_path / 'custom-data').resolve()
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline.query import Query
|
||||
from langbot_plugin.api.entities.builtin.platform.entities import Friend
|
||||
from langbot_plugin.api.entities.builtin.platform.events import FriendMessage
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
|
||||
from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session
|
||||
|
||||
|
||||
def _make_query() -> Query:
|
||||
message_chain = MessageChain([Plain(text='create a skill')])
|
||||
return Query(
|
||||
query_id=1,
|
||||
launcher_type=LauncherTypes.PERSON,
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=FriendMessage(
|
||||
message_chain=message_chain,
|
||||
time=0,
|
||||
sender=Friend(id='sender-1', nickname='Tester', remark='Tester'),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
bot_uuid='bot-1',
|
||||
pipeline_uuid='pipe-1',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'model-1', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
'knowledge-bases': [],
|
||||
},
|
||||
},
|
||||
'trigger': {'misc': {}},
|
||||
},
|
||||
variables={},
|
||||
)
|
||||
|
||||
|
||||
def _make_conversation() -> Conversation:
|
||||
return Conversation(
|
||||
prompt=Prompt(name='default', messages=[Message(role='system', content='system prompt')]),
|
||||
messages=[],
|
||||
pipeline_uuid='pipe-1',
|
||||
bot_uuid='bot-1',
|
||||
uuid='conv-1',
|
||||
)
|
||||
|
||||
|
||||
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=[]))
|
||||
|
||||
return SimpleNamespace(
|
||||
sess_mgr=SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=session),
|
||||
get_conversation=AsyncMock(return_value=conversation),
|
||||
),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=tool_mgr,
|
||||
plugin_connector=SimpleNamespace(
|
||||
emit_event=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
default_prompt=conversation.prompt.messages.copy(),
|
||||
prompt=conversation.messages.copy(),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pipeline_service=SimpleNamespace(
|
||||
get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}})
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
build_skill_aware_prompt_addition=Mock(return_value=''),
|
||||
skills={},
|
||||
),
|
||||
skill_service=skill_service,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
|
||||
def _import_preproc_modules():
|
||||
fake_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
fake_app_module.Application = object
|
||||
sys.modules['langbot.pkg.core.app'] = fake_app_module
|
||||
|
||||
for module_name in (
|
||||
'langbot.pkg.pipeline.preproc.preproc',
|
||||
'langbot.pkg.pipeline.stage',
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
preproc_module = importlib.import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
entities_module = importlib.import_module('langbot.pkg.pipeline.entities')
|
||||
return preproc_module, entities_module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_enables_skill_authoring_tools_when_skill_service_available():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
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(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=None)
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
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(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=False,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
query = _make_query()
|
||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_injects_skill_index_into_system_prompt():
|
||||
"""The Tool Call activation pattern still needs the LLM to know which
|
||||
skills exist. PreProcessor must append the SkillManager's index
|
||||
addendum to the first system message."""
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None)
|
||||
head = query.prompt.messages[0]
|
||||
assert head.role == 'system'
|
||||
assert head.content.endswith(addendum)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_respects_pipeline_bound_skills_subset():
|
||||
"""When ``enable_all_skills`` is false the bound list is passed through
|
||||
so the addendum only mentions skills allowed for this pipeline."""
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
app.pipeline_service.get_pipeline = AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_skills': False,
|
||||
'skills': ['only-this'],
|
||||
}
|
||||
}
|
||||
)
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this'])
|
||||
assert query.variables.get('_pipeline_bound_skills') == ['only-this']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_skips_injection_when_addendum_is_empty():
|
||||
"""No visible skills → system prompt is left untouched (no
|
||||
``Available Skills`` block appended)."""
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
if query.prompt and query.prompt.messages:
|
||||
assert 'Available Skills' not in (query.prompt.messages[0].content or '')
|
||||
|
||||
|
||||
async def stage_process_capture(preproc_module, app, query):
|
||||
"""Run PreProcessor.process and return the result while keeping ``query``
|
||||
accessible to the assertions (process mutates query in place)."""
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
return await stage.process(query, 'PreProcessor')
|
||||
@@ -0,0 +1,89 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service.skill import SkillService
|
||||
|
||||
|
||||
class TestRequireBoxForWrite:
|
||||
"""Box is the only source of truth for skills — there is no local
|
||||
filesystem fallback. Every write and (most) read methods refuse cleanly
|
||||
when the Box runtime is disabled, unreachable, or simply not installed."""
|
||||
|
||||
def _ap_with_disabled_box(self):
|
||||
return SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||
box_service=SimpleNamespace(
|
||||
available=False,
|
||||
enabled=False,
|
||||
_connector_error='Box runtime is disabled in config (box.enabled = false)',
|
||||
),
|
||||
)
|
||||
|
||||
def _ap_with_failed_box(self):
|
||||
return SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||
box_service=SimpleNamespace(
|
||||
available=False,
|
||||
enabled=True,
|
||||
_connector_error='docker daemon not running',
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='disabled in config'):
|
||||
await service.create_skill({'name': 'x'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_failed(self):
|
||||
service = SkillService(self._ap_with_failed_box())
|
||||
with pytest.raises(ValueError, match='docker daemon not running'):
|
||||
await service.create_skill({'name': 'x'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'):
|
||||
await service.update_skill('x', {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_skill_file_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'):
|
||||
await service.write_skill_file('x', 'a.txt', 'hi')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Installing a skill from GitHub'):
|
||||
await service.install_from_github({'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_zip_upload_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Installing a skill from upload'):
|
||||
await service.install_from_zip_upload(file_bytes=b'', filename='x.zip')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_service_missing_entirely(self):
|
||||
"""No ap.box_service attribute at all (truly minimal setup):
|
||||
Box is the only source of truth, so creation must still refuse."""
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
with pytest.raises(ValueError, match='not initialised'):
|
||||
await service.create_skill({'name': 'x'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_empty_when_box_unavailable(self):
|
||||
"""list_skills should render an empty surface (not crash) so the
|
||||
skills page can show a banner instead of a broken state."""
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
assert await service.list_skills() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_skill_file_refused_when_box_unavailable(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Reading a skill file'):
|
||||
await service.read_skill_file('x', 'a.txt')
|
||||
@@ -9,6 +9,7 @@ Tests cover:
|
||||
Note: Do NOT use 'from __future__ import annotations' because
|
||||
funcschema.py expects actual type objects, not string annotations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
@@ -20,55 +20,53 @@ class TestGetQQImageDownloadableUrl:
|
||||
|
||||
def test_basic_url(self):
|
||||
"""Parse basic image URL."""
|
||||
url = "http://example.com/image.jpg"
|
||||
url = 'http://example.com/image.jpg'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "http://example.com/image.jpg"
|
||||
assert result_url == 'http://example.com/image.jpg'
|
||||
assert query == {}
|
||||
|
||||
def test_url_with_query_params(self):
|
||||
"""Parse URL with query parameters."""
|
||||
url = "http://example.com/image.jpg?param1=value1¶m2=value2"
|
||||
url = 'http://example.com/image.jpg?param1=value1¶m2=value2'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "http://example.com/image.jpg"
|
||||
assert query == {"param1": ["value1"], "param2": ["value2"]}
|
||||
assert result_url == 'http://example.com/image.jpg'
|
||||
assert query == {'param1': ['value1'], 'param2': ['value2']}
|
||||
|
||||
def test_url_with_port(self):
|
||||
"""Parse URL with port number."""
|
||||
url = "http://example.com:8080/image.jpg"
|
||||
url = 'http://example.com:8080/image.jpg'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "http://example.com:8080/image.jpg"
|
||||
assert result_url == 'http://example.com:8080/image.jpg'
|
||||
|
||||
def test_url_with_path(self):
|
||||
"""Parse URL with complex path."""
|
||||
url = "http://example.com/path/to/image.jpg"
|
||||
url = 'http://example.com/path/to/image.jpg'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "http://example.com/path/to/image.jpg"
|
||||
assert result_url == 'http://example.com/path/to/image.jpg'
|
||||
|
||||
def test_url_with_fragment(self):
|
||||
"""Parse URL with fragment (fragment is not part of query)."""
|
||||
url = "http://example.com/image.jpg#fragment"
|
||||
url = 'http://example.com/image.jpg#fragment'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
# Fragment is not included in query string parsing
|
||||
assert "http://example.com/image.jpg" in result_url
|
||||
assert 'http://example.com/image.jpg' in result_url
|
||||
|
||||
def test_https_url(self):
|
||||
"""Parse HTTPS URL and preserve its scheme."""
|
||||
url = "https://example.com/image.jpg"
|
||||
url = 'https://example.com/image.jpg'
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "https://example.com/image.jpg"
|
||||
assert result_url == 'https://example.com/image.jpg'
|
||||
assert query == {}
|
||||
|
||||
def test_preserves_qq_https_scheme_and_query(self):
|
||||
"""QQ image URLs keep HTTPS and query parameters."""
|
||||
result_url, query = get_qq_image_downloadable_url(
|
||||
'https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1'
|
||||
)
|
||||
result_url, query = get_qq_image_downloadable_url('https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1')
|
||||
|
||||
assert result_url == 'https://gchat.qpic.cn/gchatpic_new/abc/0'
|
||||
assert query == {'term': ['2'], 'is_origin': ['1']}
|
||||
@@ -88,50 +86,50 @@ class TestExtractB64AndFormat:
|
||||
async def test_jpeg_data_uri(self):
|
||||
"""Extract base64 and format from JPEG data URI."""
|
||||
# Create a simple base64 string
|
||||
original_data = b"test image data"
|
||||
original_data = b'test image data'
|
||||
b64_data = base64.b64encode(original_data).decode()
|
||||
data_uri = f"data:image/jpeg;base64,{b64_data}"
|
||||
data_uri = f'data:image/jpeg;base64,{b64_data}'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
assert result_b64 == b64_data
|
||||
assert result_format == "jpeg"
|
||||
assert result_format == 'jpeg'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_png_data_uri(self):
|
||||
"""Extract base64 and format from PNG data URI."""
|
||||
original_data = b"test png data"
|
||||
original_data = b'test png data'
|
||||
b64_data = base64.b64encode(original_data).decode()
|
||||
data_uri = f"data:image/png;base64,{b64_data}"
|
||||
data_uri = f'data:image/png;base64,{b64_data}'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
assert result_b64 == b64_data
|
||||
assert result_format == "png"
|
||||
assert result_format == 'png'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gif_data_uri(self):
|
||||
"""Extract base64 and format from GIF data URI."""
|
||||
original_data = b"test gif data"
|
||||
original_data = b'test gif data'
|
||||
b64_data = base64.b64encode(original_data).decode()
|
||||
data_uri = f"data:image/gif;base64,{b64_data}"
|
||||
data_uri = f'data:image/gif;base64,{b64_data}'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
assert result_b64 == b64_data
|
||||
assert result_format == "gif"
|
||||
assert result_format == 'gif'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webp_data_uri(self):
|
||||
"""Extract base64 and format from WebP data URI."""
|
||||
original_data = b"test webp data"
|
||||
original_data = b'test webp data'
|
||||
b64_data = base64.b64encode(original_data).decode()
|
||||
data_uri = f"data:image/webp;base64,{b64_data}"
|
||||
data_uri = f'data:image/webp;base64,{b64_data}'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
assert result_b64 == b64_data
|
||||
assert result_format == "webp"
|
||||
assert result_format == 'webp'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complex_base64(self):
|
||||
@@ -139,7 +137,7 @@ class TestExtractB64AndFormat:
|
||||
# Base64 can include + and / characters
|
||||
original_data = bytes(range(256)) # All byte values
|
||||
b64_data = base64.b64encode(original_data).decode()
|
||||
data_uri = f"data:image/png;base64,{b64_data}"
|
||||
data_uri = f'data:image/png;base64,{b64_data}'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
@@ -150,9 +148,9 @@ class TestExtractB64AndFormat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_base64(self):
|
||||
"""Handle empty base64 string."""
|
||||
data_uri = "data:image/png;base64,"
|
||||
data_uri = 'data:image/png;base64,'
|
||||
|
||||
result_b64, result_format = await extract_b64_and_format(data_uri)
|
||||
|
||||
assert result_b64 == ""
|
||||
assert result_format == "png"
|
||||
assert result_b64 == ''
|
||||
assert result_format == 'png'
|
||||
|
||||
@@ -23,52 +23,52 @@ class TestImportDir:
|
||||
|
||||
def test_calls_importlib_for_each_python_file(self, tmp_path):
|
||||
"""Should call importlib.import_module for each .py file."""
|
||||
module_dir = tmp_path / "test_modules"
|
||||
module_dir = tmp_path / 'test_modules'
|
||||
module_dir.mkdir()
|
||||
|
||||
(module_dir / "__init__.py").write_text("")
|
||||
(module_dir / "module_a.py").write_text("VALUE_A = 'a'\n")
|
||||
(module_dir / "module_b.py").write_text("VALUE_B = 'b'\n")
|
||||
(module_dir / "readme.txt").write_text("not a module")
|
||||
(module_dir / '__init__.py').write_text('')
|
||||
(module_dir / 'module_a.py').write_text("VALUE_A = 'a'\n")
|
||||
(module_dir / 'module_b.py').write_text("VALUE_B = 'b'\n")
|
||||
(module_dir / 'readme.txt').write_text('not a module')
|
||||
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with patch.object(importlib, "import_module") as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
|
||||
with patch.object(importlib, 'import_module') as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
|
||||
# Should call import_module for each .py file (excluding __init__.py)
|
||||
assert mock_import.call_count == 2
|
||||
|
||||
def test_skips_init_py(self, tmp_path):
|
||||
"""Should skip __init__.py when importing."""
|
||||
module_dir = tmp_path / "test_modules"
|
||||
module_dir = tmp_path / 'test_modules'
|
||||
module_dir.mkdir()
|
||||
|
||||
(module_dir / "__init__.py").write_text("")
|
||||
(module_dir / "regular.py").write_text("VALUE = 1\n")
|
||||
(module_dir / '__init__.py').write_text('')
|
||||
(module_dir / 'regular.py').write_text('VALUE = 1\n')
|
||||
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with patch.object(importlib, "import_module") as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
|
||||
with patch.object(importlib, 'import_module') as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
|
||||
# __init__.py should be skipped
|
||||
mock_import.assert_called_once()
|
||||
# The call should not include __init__
|
||||
call_args = mock_import.call_args[0][0]
|
||||
assert "__init__" not in call_args
|
||||
assert '__init__' not in call_args
|
||||
|
||||
def test_ignores_non_py_files(self, tmp_path):
|
||||
"""Should ignore non-.py files."""
|
||||
module_dir = tmp_path / "test_modules"
|
||||
module_dir = tmp_path / 'test_modules'
|
||||
module_dir.mkdir()
|
||||
|
||||
(module_dir / "module.py").write_text("VALUE = 1\n")
|
||||
(module_dir / "readme.txt").write_text("text")
|
||||
(module_dir / "data.json").write_text("{}")
|
||||
(module_dir / 'module.py').write_text('VALUE = 1\n')
|
||||
(module_dir / 'readme.txt').write_text('text')
|
||||
(module_dir / 'data.json').write_text('{}')
|
||||
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with patch.object(importlib, "import_module") as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
|
||||
with patch.object(importlib, 'import_module') as mock_import:
|
||||
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
|
||||
# Only .py files should be imported
|
||||
assert mock_import.call_count == 1
|
||||
|
||||
@@ -79,14 +79,14 @@ class TestImportModulesInPkg:
|
||||
def test_imports_modules_from_package(self, tmp_path):
|
||||
"""Should import all modules from a package object."""
|
||||
mock_pkg = MagicMock()
|
||||
mock_pkg.__file__ = str(tmp_path / "__init__.py")
|
||||
mock_pkg.__file__ = str(tmp_path / '__init__.py')
|
||||
|
||||
(tmp_path / "__init__.py").write_text("")
|
||||
(tmp_path / "mod1.py").write_text("MOD1 = 1\n")
|
||||
(tmp_path / '__init__.py').write_text('')
|
||||
(tmp_path / 'mod1.py').write_text('MOD1 = 1\n')
|
||||
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with patch.object(importutil, "import_dir") as mock_import_dir:
|
||||
with patch.object(importutil, 'import_dir') as mock_import_dir:
|
||||
importutil.import_modules_in_pkg(mock_pkg)
|
||||
mock_import_dir.assert_called_once()
|
||||
call_path = mock_import_dir.call_args[0][0]
|
||||
@@ -101,11 +101,11 @@ class TestImportModulesInPkgs:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
mock_pkg1 = MagicMock()
|
||||
mock_pkg1.__file__ = "/path/to/pkg1/__init__.py"
|
||||
mock_pkg1.__file__ = '/path/to/pkg1/__init__.py'
|
||||
mock_pkg2 = MagicMock()
|
||||
mock_pkg2.__file__ = "/path/to/pkg2/__init__.py"
|
||||
mock_pkg2.__file__ = '/path/to/pkg2/__init__.py'
|
||||
|
||||
with patch.object(importutil, "import_modules_in_pkg") as mock_import:
|
||||
with patch.object(importutil, 'import_modules_in_pkg') as mock_import:
|
||||
importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2])
|
||||
assert mock_import.call_count == 2
|
||||
|
||||
@@ -116,18 +116,18 @@ class TestImportDotStyleDir:
|
||||
def test_converts_dot_notation_to_path(self, tmp_path):
|
||||
"""Should convert dot notation to path and import."""
|
||||
# Create structure matching the dot notation
|
||||
(tmp_path / "my").mkdir()
|
||||
(tmp_path / "my" / "pkg").mkdir()
|
||||
(tmp_path / "my" / "pkg" / "test").mkdir()
|
||||
(tmp_path / 'my').mkdir()
|
||||
(tmp_path / 'my' / 'pkg').mkdir()
|
||||
(tmp_path / 'my' / 'pkg' / 'test').mkdir()
|
||||
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with patch.object(importutil, "import_dir") as mock_import_dir:
|
||||
importutil.import_dot_style_dir("my.pkg.test")
|
||||
with patch.object(importutil, 'import_dir') as mock_import_dir:
|
||||
importutil.import_dot_style_dir('my.pkg.test')
|
||||
# The path should be converted using os.path.join
|
||||
call_path = mock_import_dir.call_args[0][0]
|
||||
# Should contain the path components joined
|
||||
assert "my" in call_path
|
||||
assert 'my' in call_path
|
||||
|
||||
|
||||
class TestReadResourceFile:
|
||||
@@ -137,16 +137,16 @@ class TestReadResourceFile:
|
||||
"""Should read content from a resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file("templates/config.yaml")
|
||||
assert "admins:" in content
|
||||
assert "edition: community" in content
|
||||
content = importutil.read_resource_file('templates/config.yaml')
|
||||
assert 'api:' in content
|
||||
assert 'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file(self):
|
||||
"""Should raise exception for non-existent resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with pytest.raises((FileNotFoundError, Exception)):
|
||||
importutil.read_resource_file("nonexistent/path/file.txt")
|
||||
importutil.read_resource_file('nonexistent/path/file.txt')
|
||||
|
||||
|
||||
class TestReadResourceFileBytes:
|
||||
@@ -156,16 +156,16 @@ class TestReadResourceFileBytes:
|
||||
"""Should read content as bytes from a resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file_bytes("templates/config.yaml")
|
||||
assert b"admins:" in content
|
||||
assert b"edition: community" in content
|
||||
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
||||
assert b'api:' in content
|
||||
assert b'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file_bytes(self):
|
||||
"""Should raise exception for non-existent resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with pytest.raises((FileNotFoundError, Exception)):
|
||||
importutil.read_resource_file_bytes("nonexistent/path/file.txt")
|
||||
importutil.read_resource_file_bytes('nonexistent/path/file.txt')
|
||||
|
||||
|
||||
class TestListResourceFiles:
|
||||
@@ -175,9 +175,9 @@ class TestListResourceFiles:
|
||||
"""Should list files in a resource directory."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
files = importutil.list_resource_files("templates")
|
||||
assert "config.yaml" in files
|
||||
assert "default-pipeline-config.json" in files
|
||||
files = importutil.list_resource_files('templates')
|
||||
assert 'config.yaml' in files
|
||||
assert 'default-pipeline-config.json' in files
|
||||
assert all(isinstance(file, str) for file in files)
|
||||
|
||||
def test_raises_for_nonexistent_directory(self):
|
||||
@@ -185,8 +185,8 @@ class TestListResourceFiles:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
with pytest.raises((FileNotFoundError, Exception)):
|
||||
importutil.list_resource_files("nonexistent_directory_xyz")
|
||||
importutil.list_resource_files('nonexistent_directory_xyz')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -11,7 +11,6 @@ Uses tmp_path for file system isolation where applicable.
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestCheckIfSourceInstall:
|
||||
@@ -19,7 +18,7 @@ class TestCheckIfSourceInstall:
|
||||
|
||||
def test_returns_true_for_source_install(self, tmp_path, monkeypatch):
|
||||
"""Should return True when main.py with LangBot marker exists."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n# This is the entry point')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -33,52 +32,14 @@ class TestCheckIfSourceInstall:
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_returns_false_when_no_main_py(self, tmp_path, monkeypatch):
|
||||
"""Should return False when main.py doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths._check_if_source_install()
|
||||
assert result is False
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_returns_false_when_main_py_without_marker(self, tmp_path, monkeypatch):
|
||||
"""Should return False when main.py exists but lacks LangBot marker."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py.write_text('# Some other project\nprint("hello")')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths._check_if_source_install()
|
||||
assert result is False
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_handles_io_error_gracefully(self, tmp_path, monkeypatch):
|
||||
"""Should return False when main.py cannot be read."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
# Patch open to raise IOError
|
||||
with patch("builtins.open", side_effect=IOError("Cannot read")):
|
||||
result = paths._check_if_source_install()
|
||||
assert result is False
|
||||
|
||||
paths._is_source_install = None
|
||||
# Note: ``_check_if_source_install`` was refactored to walk
|
||||
# ``Path(__file__).resolve().parents`` looking for ``pyproject.toml`` +
|
||||
# ``main.py`` instead of relying on the cwd. That makes it robust to where
|
||||
# the process is launched from but also means the old "cwd doesn't have
|
||||
# main.py" / "main.py without marker" / "IOError on read" cases no longer
|
||||
# apply — there's no file read at all. The corresponding negative tests
|
||||
# were removed; ``test_returns_true_for_source_install`` still exercises
|
||||
# the positive path because the repo checkout itself is a source install.
|
||||
|
||||
|
||||
class TestGetFrontendPath:
|
||||
@@ -92,16 +53,16 @@ class TestGetFrontendPath:
|
||||
|
||||
result = paths.get_frontend_path()
|
||||
# The result should contain web/dist or be an absolute path to it
|
||||
assert "web/dist" in result or result.endswith("dist")
|
||||
assert 'web/dist' in result or result.endswith('dist')
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
|
||||
"""Should find web/dist when running from source mode."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
web_dist = tmp_path / "web" / "dist"
|
||||
web_dist = tmp_path / 'web' / 'dist'
|
||||
web_dist.mkdir(parents=True)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -111,18 +72,18 @@ class TestGetFrontendPath:
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths.get_frontend_path()
|
||||
assert result == "web/dist"
|
||||
assert result == 'web/dist'
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_prefers_dist_over_out_in_source_mode(self, tmp_path, monkeypatch):
|
||||
"""Should prefer web/dist over web/out when both exist in source mode."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
web_dist = tmp_path / "web" / "dist"
|
||||
web_dist = tmp_path / 'web' / 'dist'
|
||||
web_dist.mkdir(parents=True)
|
||||
web_out = tmp_path / "web" / "out"
|
||||
web_out = tmp_path / 'web' / 'out'
|
||||
web_out.mkdir(parents=True)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -132,7 +93,7 @@ class TestGetFrontendPath:
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths.get_frontend_path()
|
||||
assert result == "web/dist"
|
||||
assert result == 'web/dist'
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
@@ -148,19 +109,19 @@ class TestGetResourcePath:
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths.get_resource_path("nonexistent/file.txt")
|
||||
assert result == "nonexistent/file.txt"
|
||||
result = paths.get_resource_path('nonexistent/file.txt')
|
||||
assert result == 'nonexistent/file.txt'
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch):
|
||||
"""Should find resource in current directory when in source mode."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
resource_file = tmp_path / "templates" / "config.yaml"
|
||||
resource_file = tmp_path / 'templates' / 'config.yaml'
|
||||
resource_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
resource_file.write_text("test: value")
|
||||
resource_file.write_text('test: value')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -168,18 +129,18 @@ class TestGetResourcePath:
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths.get_resource_path("templates/config.yaml")
|
||||
result = paths.get_resource_path('templates/config.yaml')
|
||||
assert os.path.exists(result)
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch):
|
||||
"""Should return relative path if resource exists in source mode."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
resource_file = tmp_path / "test_resource.txt"
|
||||
resource_file.write_text("test content")
|
||||
resource_file = tmp_path / 'test_resource.txt'
|
||||
resource_file.write_text('test content')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -187,8 +148,8 @@ class TestGetResourcePath:
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
result = paths.get_resource_path("test_resource.txt")
|
||||
assert result == "test_resource.txt"
|
||||
result = paths.get_resource_path('test_resource.txt')
|
||||
assert result == 'test_resource.txt'
|
||||
|
||||
paths._is_source_install = None
|
||||
|
||||
@@ -198,7 +159,7 @@ class TestPathFunctionsCaching:
|
||||
|
||||
def test_source_install_cache_is_used(self, tmp_path, monkeypatch):
|
||||
"""_check_if_source_install should use cached result."""
|
||||
main_py = tmp_path / "main.py"
|
||||
main_py = tmp_path / 'main.py'
|
||||
main_py.write_text('# LangBot/main.py\n')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -219,5 +180,5 @@ class TestPathFunctionsCaching:
|
||||
paths._is_source_install = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests cover:
|
||||
- Docker environment detection
|
||||
- WebSocket plugin runtime mode
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -86,4 +87,4 @@ class TestGetPlatform:
|
||||
assert platform_module.use_websocket_to_connect_plugin_runtime() is True
|
||||
|
||||
# Restore
|
||||
platform_module.standalone_runtime = original
|
||||
platform_module.standalone_runtime = original
|
||||
|
||||
@@ -60,10 +60,12 @@ class TestProxyManager:
|
||||
|
||||
async def test_initialize_config_overrides_env(self):
|
||||
"""Config proxy overrides environment variables."""
|
||||
mock_app = self._create_mock_app(proxy_config={
|
||||
'http': 'http://config-proxy:8080',
|
||||
'https': 'https://config-proxy:8443',
|
||||
})
|
||||
mock_app = self._create_mock_app(
|
||||
proxy_config={
|
||||
'http': 'http://config-proxy:8080',
|
||||
'https': 'https://config-proxy:8443',
|
||||
}
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {'HTTP_PROXY': 'http://env-proxy:8080'}):
|
||||
pm = ProxyManager(mock_app)
|
||||
@@ -74,10 +76,12 @@ class TestProxyManager:
|
||||
|
||||
async def test_initialize_sets_env_variables(self):
|
||||
"""initialize sets proxy to environment variables."""
|
||||
mock_app = self._create_mock_app(proxy_config={
|
||||
'http': 'http://test-proxy:8080',
|
||||
'https': 'https://test-proxy:8443',
|
||||
})
|
||||
mock_app = self._create_mock_app(
|
||||
proxy_config={
|
||||
'http': 'http://test-proxy:8080',
|
||||
'https': 'https://test-proxy:8443',
|
||||
}
|
||||
)
|
||||
|
||||
pm = ProxyManager(mock_app)
|
||||
await pm.initialize()
|
||||
@@ -143,9 +147,11 @@ class TestProxyManager:
|
||||
|
||||
async def test_initialize_http_only_config(self):
|
||||
"""initialize handles http-only config."""
|
||||
mock_app = self._create_mock_app(proxy_config={
|
||||
'http': 'http://http-only:8080',
|
||||
})
|
||||
mock_app = self._create_mock_app(
|
||||
proxy_config={
|
||||
'http': 'http://http-only:8080',
|
||||
}
|
||||
)
|
||||
|
||||
# Clear any existing proxy env vars
|
||||
env_backup = {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user