mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 06:37:14 +00:00
Merge remote-tracking branch 'origin/master' into dev/4.11.x
# Conflicts: # src/langbot/pkg/pipeline/preproc/preproc.py # src/langbot/pkg/pipeline/process/handlers/chat.py # src/langbot/pkg/provider/runners/localagent.py # src/langbot/pkg/provider/tools/toolmgr.py # src/langbot/templates/metadata/pipeline/ai.yaml # tests/unit_tests/test_preproc.py # web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx # web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
This commit is contained in:
@@ -662,6 +662,100 @@ class TestSendResponseBackStage:
|
||||
assert len(outbound) == 1
|
||||
assert outbound[0]['type'] == 'reply'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_response_failure_notifies_plugin_diagnostic(self, pipeline_app):
|
||||
"""Plugin-provided deferred replies should report delivery failures."""
|
||||
from langbot.pkg.pipeline import plugin_diagnostics
|
||||
from langbot.pkg.pipeline.respback import respback
|
||||
from tests.factories.message import text_chain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
query = text_query('hello')
|
||||
query.adapter.reply_message.side_effect = RuntimeError('send failed')
|
||||
query.pipeline_config = create_minimal_pipeline_config()
|
||||
query.current_stage_name = 'SendResponseBackStage'
|
||||
query.resp_messages = [Message(role='assistant', content='test response')]
|
||||
query.resp_message_chain = [text_chain('test response')]
|
||||
plugin_diagnostics.record_plugin_response_source(
|
||||
query,
|
||||
0,
|
||||
[
|
||||
{
|
||||
'kind': 'reply_message_chain',
|
||||
'plugin': {'author': 'tester', 'name': 'demo'},
|
||||
}
|
||||
],
|
||||
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
|
||||
'NormalMessageResponded',
|
||||
)
|
||||
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
|
||||
|
||||
respback_stage = respback.SendResponseBackStage(pipeline_app)
|
||||
|
||||
with pytest.raises(RuntimeError, match='send failed'):
|
||||
await respback_stage.process(query, 'SendResponseBackStage')
|
||||
|
||||
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_awaited_once()
|
||||
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
|
||||
assert payload['code'] == 'response_delivery_failed'
|
||||
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
|
||||
assert payload['query']['event_name'] == 'NormalMessageResponded'
|
||||
assert payload['delivery']['error_type'] == 'RuntimeError'
|
||||
assert 'attribution_warning' not in payload['details']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_response_failure_warns_for_old_runtime_attribution(self, pipeline_app):
|
||||
"""Older plugin runtimes without response_sources should get approximate diagnostics."""
|
||||
from langbot.pkg.pipeline import plugin_diagnostics
|
||||
from langbot.pkg.pipeline.respback import respback
|
||||
from tests.factories.message import text_chain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
query = text_query('hello')
|
||||
query.adapter.reply_message.side_effect = RuntimeError('send failed')
|
||||
query.pipeline_config = create_minimal_pipeline_config()
|
||||
query.resp_messages = [Message(role='assistant', content='test response')]
|
||||
query.resp_message_chain = [text_chain('test response')]
|
||||
plugin_diagnostics.record_plugin_response_source(
|
||||
query,
|
||||
0,
|
||||
None,
|
||||
[{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}],
|
||||
'NormalMessageResponded',
|
||||
)
|
||||
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
|
||||
|
||||
respback_stage = respback.SendResponseBackStage(pipeline_app)
|
||||
|
||||
with pytest.raises(RuntimeError, match='send failed'):
|
||||
await respback_stage.process(query, 'SendResponseBackStage')
|
||||
|
||||
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
|
||||
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
|
||||
assert 'attribution_warning' in payload['details']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_response_failure_ignores_query_variable_spoofing(self, pipeline_app):
|
||||
"""Plugin-controlled query variables must not mask delivery failures."""
|
||||
from langbot.pkg.pipeline.respback import respback
|
||||
from tests.factories.message import text_chain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
query = text_query('hello')
|
||||
query.adapter.reply_message.side_effect = RuntimeError('send failed')
|
||||
query.pipeline_config = create_minimal_pipeline_config()
|
||||
query.resp_messages = [Message(role='assistant', content='test response')]
|
||||
query.resp_message_chain = [text_chain('test response')]
|
||||
query.variables['_plugin_response_sources'] = {0: ['malformed']}
|
||||
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
|
||||
|
||||
respback_stage = respback.SendResponseBackStage(pipeline_app)
|
||||
|
||||
with pytest.raises(RuntimeError, match='send failed'):
|
||||
await respback_stage.process(query, 'SendResponseBackStage')
|
||||
|
||||
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestStageChainIntegration:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -348,6 +348,8 @@ class TestPipelineServiceCreatePipeline:
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
|
||||
@@ -814,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."""
|
||||
|
||||
@@ -273,6 +273,31 @@ class TestSharesFilesystemWithBox:
|
||||
assert service.shares_filesystem_with_box is False
|
||||
|
||||
|
||||
def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_path):
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
host_root = tmp_path / 'box'
|
||||
service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
service._shares_filesystem_with_box_override = False
|
||||
|
||||
service._ensure_default_workspace()
|
||||
|
||||
assert not (host_root / 'default').exists()
|
||||
|
||||
|
||||
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
host_root = tmp_path / 'box'
|
||||
service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
service._shares_filesystem_with_box_override = False
|
||||
|
||||
spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'missing-host-path'})
|
||||
|
||||
assert spec.host_path == str(host_root / 'default')
|
||||
assert not (host_root / 'default').exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_get_sessions_delegates_to_client():
|
||||
client = Mock()
|
||||
@@ -517,6 +542,7 @@ async def test_box_service_creates_default_workspace_on_initialize(tmp_path):
|
||||
app = make_app(logger, [str(allowed_root)])
|
||||
app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace)
|
||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
service._shares_filesystem_with_box_override = True
|
||||
|
||||
await service.initialize()
|
||||
|
||||
@@ -531,6 +557,7 @@ async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp
|
||||
shared_root = tmp_path / 'shared-box-root'
|
||||
app = make_app(logger, host_root=str(shared_root))
|
||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
service._shares_filesystem_with_box_override = True
|
||||
|
||||
await service.initialize()
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -461,3 +462,60 @@ class TestPreProcessorVariables:
|
||||
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']
|
||||
|
||||
@@ -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 {
|
||||
@@ -106,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."""
|
||||
@@ -421,6 +465,104 @@ class TestResponseWrapperCustomReply:
|
||||
chain = results[0].new_query.resp_message_chain[0]
|
||||
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:
|
||||
"""Tests for bound plugins variable."""
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter, 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'},
|
||||
}
|
||||
@@ -13,6 +13,8 @@ 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."""
|
||||
@@ -132,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."""
|
||||
|
||||
|
||||
@@ -159,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."""
|
||||
|
||||
|
||||
@@ -68,13 +68,15 @@ class TestRagRerankAction:
|
||||
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},
|
||||
})
|
||||
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}]
|
||||
@@ -89,16 +91,16 @@ class TestRagRerankAction:
|
||||
@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')
|
||||
)
|
||||
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'],
|
||||
})
|
||||
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
|
||||
@@ -461,9 +463,7 @@ class TestAgentRunProxyActions:
|
||||
return SimpleNamespace(
|
||||
pipeline_config={'output': {'misc': {'remove-think': remove_think}}},
|
||||
variables={},
|
||||
prompt=SimpleNamespace(
|
||||
messages=[provider_message.Message(role='system', content='effective prompt')]
|
||||
),
|
||||
prompt=SimpleNamespace(messages=[provider_message.Message(role='system', content='effective prompt')]),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -489,10 +489,12 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -533,19 +535,23 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}],
|
||||
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [
|
||||
{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}
|
||||
],
|
||||
'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -601,12 +607,14 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_usage_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_usage_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -645,19 +653,23 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_count_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}],
|
||||
'extra_args': {'temperature': 0.7},
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_count_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [
|
||||
{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}
|
||||
],
|
||||
'extra_args': {'temperature': 0.7},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -690,12 +702,14 @@ class TestAgentRunProxyActions:
|
||||
|
||||
runtime_handler = make_handler(app)
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_count_002',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_count_002',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -740,20 +754,24 @@ class TestAgentRunProxyActions:
|
||||
|
||||
responses = []
|
||||
try:
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}],
|
||||
'extra_args': {'max_tokens': 256},
|
||||
'remove_think': True,
|
||||
})
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
'funcs': [
|
||||
{
|
||||
'name': 'search',
|
||||
'human_desc': 'Search',
|
||||
'description': 'Search',
|
||||
'parameters': {'type': 'object'},
|
||||
}
|
||||
],
|
||||
'extra_args': {'max_tokens': 256},
|
||||
'remove_think': True,
|
||||
}
|
||||
)
|
||||
async for response in stream:
|
||||
responses.append(response)
|
||||
finally:
|
||||
@@ -799,12 +817,14 @@ class TestAgentRunProxyActions:
|
||||
|
||||
responses = []
|
||||
try:
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_002',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
})
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_002',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
}
|
||||
)
|
||||
async for response in stream:
|
||||
responses.append(response)
|
||||
finally:
|
||||
@@ -854,12 +874,14 @@ class TestAgentRunProxyActions:
|
||||
|
||||
responses = []
|
||||
try:
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_usage_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
})
|
||||
stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'llm_model_uuid': 'llm_stream_usage_001',
|
||||
'messages': [{'role': 'user', 'content': 'hello'}],
|
||||
}
|
||||
)
|
||||
async for response in stream:
|
||||
responses.append(response)
|
||||
finally:
|
||||
@@ -892,12 +914,14 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'tool_name': 'test/search',
|
||||
'parameters': {'q': 'langbot'},
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'tool_name': 'test/search',
|
||||
'parameters': {'q': 'langbot'},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@@ -926,10 +950,12 @@ class TestAgentRunProxyActions:
|
||||
)
|
||||
|
||||
provider = SimpleNamespace(
|
||||
invoke_rerank=AsyncMock(return_value=[
|
||||
{'index': 0, 'relevance_score': 0.2},
|
||||
{'index': 1, 'relevance_score': 0.9},
|
||||
]),
|
||||
invoke_rerank=AsyncMock(
|
||||
return_value=[
|
||||
{'index': 0, 'relevance_score': 0.2},
|
||||
{'index': 1, 'relevance_score': 0.9},
|
||||
]
|
||||
),
|
||||
)
|
||||
rerank_model = SimpleNamespace(
|
||||
model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}),
|
||||
@@ -939,15 +965,17 @@ class TestAgentRunProxyActions:
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
try:
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'rerank_model_uuid': 'rerank_001',
|
||||
'query': 'hello',
|
||||
'documents': ['a', 'b'],
|
||||
'top_k': 1,
|
||||
'extra_args': {'top_n': 2},
|
||||
})
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
|
||||
{
|
||||
'run_id': run_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
'rerank_model_uuid': 'rerank_001',
|
||||
'query': 'hello',
|
||||
'documents': ['a', 'b'],
|
||||
'top_k': 1,
|
||||
'extra_args': {'top_n': 2},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
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={})
|
||||
|
||||
|
||||
@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()
|
||||
@@ -15,13 +15,35 @@ from langbot.pkg.provider.tools.toolmgr import ToolManager
|
||||
|
||||
|
||||
class StubLoader:
|
||||
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
|
||||
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)
|
||||
|
||||
@@ -70,6 +92,28 @@ async def test_tool_manager_omits_skill_tools_when_loader_unavailable():
|
||||
assert [tool.name for tool in tools] == ['exec', '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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.langbot.pkg.utils import paths
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
|
||||
def test_get_data_root_uses_source_root_in_repo_checkout():
|
||||
|
||||
@@ -159,7 +159,11 @@ async def test_preproc_loads_host_tools_for_runner():
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -180,7 +184,11 @@ async def test_preproc_puts_host_skill_tools_into_query_scope():
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
assert [tool.name for tool in query.use_funcs] == ['activate', 'register_skill']
|
||||
|
||||
|
||||
@@ -195,7 +203,30 @@ async def test_preproc_loads_host_tools_regardless_of_skill_service():
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
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_mcp_resource_tools=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestReadResourceFile:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file('templates/config.yaml')
|
||||
assert 'admins:' in content
|
||||
assert 'api:' in content
|
||||
assert 'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file(self):
|
||||
@@ -157,7 +157,7 @@ class TestReadResourceFileBytes:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
||||
assert b'admins:' in content
|
||||
assert b'api:' in content
|
||||
assert b'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file_bytes(self):
|
||||
|
||||
Reference in New Issue
Block a user