mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 14:47:13 +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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user