Merge pull request #2678 from langbot-app/test/quality-runtime-followup-20261017

test(pipeline): verify message and provider behavior
This commit is contained in:
huanghuoguoguo
2026-10-17 20:10:49 +08:00
committed by GitHub
11 changed files with 184 additions and 87 deletions
+59 -16
View File
@@ -465,8 +465,19 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid=None, # None -> disabled
)
# Should have called query_pool.add_query
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
@pytest.mark.asyncio
async def test_enabled_buffers_message(self):
@@ -505,8 +516,27 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid='test-pipeline',
)
# Should have buffered the message
assert len(agg.buffers) == 1
context = execution_context(pipeline_uuid='test-pipeline')
key = aggregation_key(context, pipeline_uuid='test-pipeline')
assert list(agg.buffers) == [key]
assert agg.buffers[key].execution_context == context
assert len(agg.buffers[key].messages) == 1
pending = agg.buffers[key].messages[0]
assert {key: value for key, value in vars(pending).items() if key != 'timestamp'} == {
'execution_context': context,
'bot_uuid': 'test-bot',
'launcher_type': provider_session.LauncherTypes.PERSON,
'launcher_id': 12345,
'sender_id': 12345,
'message_event': event,
'message_chain': chain,
'adapter': adapter,
'pipeline_uuid': 'test-pipeline',
'routed_by_rule': False,
'variables': None,
}
app.query_pool.add_query.assert_not_awaited()
await agg.flush_all()
@pytest.mark.asyncio
async def test_control_variables_bypass_aggregation(self):
@@ -665,10 +695,7 @@ class TestMessageAggregatorMerge:
merged = agg._merge_messages([pending1, pending2])
# Should contain both messages with separator
merged_str = str(merged.message_chain)
assert 'hello' in merged_str
assert 'world' in merged_str
assert str(merged.message_chain) == 'hello\nworld'
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."""
@@ -767,7 +794,19 @@ class TestMessageAggregatorFlush:
await agg._flush_buffer(key, context)
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=context,
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
assert key not in agg.buffers
@pytest.mark.asyncio
@@ -832,8 +871,8 @@ class TestMessageAggregatorFlushAll:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain('hello')
event = friend_message_event(chain)
first_chain = text_chain('first')
second_chain = text_chain('second')
adapter = mock_adapter()
# Create two buffers
@@ -843,8 +882,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
message_event=friend_message_event(first_chain),
message_chain=first_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -855,8 +894,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=67890,
sender_id=67890,
message_event=event,
message_chain=chain,
message_event=friend_message_event(second_chain),
message_chain=second_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -882,7 +921,11 @@ class TestMessageAggregatorFlushAll:
# Both buffers should be flushed
assert len(agg.buffers) == 0
assert app.query_pool.add_query.call_count == 2
assert app.query_pool.add_query.await_count == 2
assert [
(sent.kwargs['launcher_id'], str(sent.kwargs['message_chain']), sent.kwargs['execution_context'])
for sent in app.query_pool.add_query.await_args_list
] == [(12345, 'first', context), (67890, 'second', context)]
class TestMessageAggregatorWorkspaceIsolation:
+38 -6
View File
@@ -89,6 +89,25 @@ def set_runner(fake_app):
return _set_runner
@pytest.fixture
def chat_session():
from langbot_plugin.api.entities.builtin.provider.session import Session, Conversation, LauncherTypes
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
return Session(
launcher_type=LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
bot_uuid='test-bot-uuid',
using_conversation=Conversation(
prompt=Prompt(name='default', messages=[]),
messages=[],
pipeline_uuid='test-pipeline-uuid',
bot_uuid='test-bot-uuid',
),
)
# ============== CACHED LAZY IMPORTS ==============
_chat_handler_module = None
@@ -227,7 +246,9 @@ class TestChatMessageHandlerReal:
assert received_contents == [expected_content]
@pytest.mark.asyncio
async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner):
async def test_adapter_without_stream_method_defaults_non_stream(
self, fake_app, mock_event_ctx, set_runner, chat_session
):
"""Adapter without is_stream_output_supported defaults to non-stream."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
@@ -241,6 +262,7 @@ class TestChatMessageHandlerReal:
query = text_query('test')
query.adapter = Mock(spec=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class SingleRunner:
name = 'local-agent'
@@ -260,7 +282,9 @@ class TestChatMessageHandlerReal:
async for result in handler.handle(query):
results.append(result)
assert len(results) >= 1
assert len(results) == 1
assert results[0].result_type == get_entities().ResultType.CONTINUE
assert [(message.role, message.content) for message in query.resp_messages] == [('assistant', 'response')]
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -319,7 +343,7 @@ class TestChatHandlerStreaming:
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner, chat_session):
"""Streaming produces multiple results."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk
@@ -334,6 +358,7 @@ class TestChatHandlerStreaming:
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
query.adapter.create_message_card = AsyncMock()
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class StreamRunner:
name = 'local-agent'
@@ -344,17 +369,24 @@ class TestChatHandlerStreaming:
async def run(self, query):
yield MessageChunk(role='assistant', content='Hello', is_final=False)
yield MessageChunk(role='assistant', content=' World', is_final=True)
yield MessageChunk(role='assistant', content='Hello World', is_final=True)
set_runner(StreamRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
delivered = []
async for result in handler.handle(query):
results.append(result)
delivered.append(query.resp_messages[-1])
assert len(results) >= 1
assert len(results) == 2
assert all(result.result_type == get_entities().ResultType.CONTINUE for result in results)
assert [chunk.content for chunk in delivered] == ['Hello', 'Hello World']
assert [chunk.is_final for chunk in delivered] == [False, True]
assert len({chunk.resp_message_id for chunk in delivered}) == 1
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -540,7 +572,7 @@ class TestChatHandlerHelper:
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
def test_response_size_limit_uses_instance_config(self, fake_app):
from langbot_plugin.api.entities.builtin.provider.message import Message
@@ -367,6 +367,8 @@ class TestCommandHandlerReal:
assert len(msg.content) == 2
assert msg.content[0].type == 'text'
assert msg.content[1].type == 'image_url'
assert msg.content[0].text == 'Here is the image:'
assert msg.content[1].image_url.url == 'https://example.com/image.png'
@pytest.mark.asyncio
async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory):
@@ -410,4 +412,4 @@ class TestCommandHandlerHelper:
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
+8 -8
View File
@@ -13,6 +13,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,
@@ -73,7 +74,7 @@ class TestLongTextProcessStageInit:
async def test_initialize_forward_strategy(self):
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -82,8 +83,7 @@ class TestLongTextProcessStageInit:
await stage.initialize(pipeline_config)
assert stage.strategy_impl is not None
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
@pytest.mark.asyncio
async def test_initialize_unknown_strategy_raises(self):
@@ -340,10 +340,11 @@ class TestLongTextProcessStageImageStrategy:
"""Tests for image strategy handling (requires PIL/font)."""
@pytest.mark.asyncio
async def test_image_strategy_missing_font_fallback(self):
async def test_image_strategy_missing_font_fallback(self, monkeypatch):
"""Missing font should fallback to forward strategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
monkeypatch.setattr(longtext, 'os', SimpleNamespace(name='posix', path=SimpleNamespace(exists=lambda _: False)))
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -354,6 +355,5 @@ class TestLongTextProcessStageImageStrategy:
# On non-Windows without font, should fallback to forward
await stage.initialize(pipeline_config)
# Should have initialized (possibly with fallback strategy)
if stage.strategy_impl is not None:
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert pipeline_config['output']['long-text-processing']['strategy'] == 'forward'
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
+33 -12
View File
@@ -190,6 +190,7 @@ class TestPreProcessorNormalText:
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [('text', 'test message')]
class TestPreProcessorEmptyMessage:
@@ -254,6 +255,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -261,9 +263,8 @@ class TestPreProcessorImageSegment:
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
# Image query with base64
# Exercise both supported image transports.
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(
@@ -283,17 +284,23 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
assert result.new_query.use_llm_model_uuid == 'vision-model'
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
expected_image = (
ContentElement.from_image_base64('data:image/png;base64,abc123')
if image_source == 'base64'
else ContentElement.from_image_url('https://cdn.example/image.png')
)
assert result.new_query.user_message.content == [ContentElement.from_text('look at this'), expected_image]
contents = [part.model_dump() for part in result.new_query.user_message.content]
attachments = QueryEntryAdapter._build_attachments(result.new_query, contents)
assert len(attachments) == 1
if image_source == 'url':
assert attachments[0]['url'] == 'https://cdn.example/image.png'
else:
assert attachments[0].get('content') or attachments[0].get('url')
assert attachments[0]['content'] == 'data:image/png;base64,abc123'
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
@@ -315,6 +322,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -327,6 +335,12 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
assert result.new_query.use_llm_model_uuid == 'text-only-model'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [
('text', 'describe this'),
('text', '[Image]'),
]
assert result.new_query.variables['user_message_text'] == 'describe this[Image]'
class TestPreProcessorModelSelection:
@@ -471,11 +485,18 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'launcher_type' in variables
assert 'launcher_id' in variables
assert 'sender_id' in variables
assert variables['sender_id'] == 67890
assert 'user_message_text' in variables
expected = {
'launcher_type': 'person',
'launcher_id': 12345,
'sender_id': 67890,
'session_id': 'person_12345',
'conversation_id': 'conv-123',
'msg_create_time': 1609459200,
'group_name': '',
'sender_name': 'TestUser',
'user_message_text': 'hello',
}
assert {key: variables[key] for key in expected} == expected
@pytest.mark.asyncio
async def test_group_variables_include_group_name(self):
@@ -509,8 +530,8 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'group_name' in variables
assert 'sender_name' in variables
assert variables['group_name'] == 'TestGroup'
assert variables['sender_name'] == 'TestUser'
class TestPreProcessorToolSelection:
+6 -3
View File
@@ -272,11 +272,14 @@ class TestFixedWindowAlgo:
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# release_access is empty in current implementation
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
containers = dict(algo.containers)
records = {key: dict(container.records) for key, container in containers.items()}
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
assert algo.containers == containers
assert {key: dict(container.records) for key, container in algo.containers.items()} == records
# Original mock-based tests for RateLimit stage integration
+23 -12
View File
@@ -22,6 +22,7 @@ from tests.factories import (
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot_plugin.api.entities.builtin.provider.message import Message
def get_wrapper_module():
@@ -109,7 +110,7 @@ class TestResponseWrapperMessageChain:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(results[0].new_query.resp_message_chain) == 1
assert [str(chain) for chain in results[0].new_query.resp_message_chain] == ['response']
@pytest.mark.asyncio
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
@@ -171,12 +172,7 @@ class TestResponseWrapperCommand:
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create a command response message
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')])
)
command_resp = Message(role='command', content='Help info')
query.resp_messages = [command_resp]
results = []
@@ -185,8 +181,7 @@ class TestResponseWrapperCommand:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Check that prefix was added (via get_content_platform_message_chain)
command_resp.get_content_platform_message_chain.assert_called_once()
assert [str(chain) for chain in query.resp_message_chain] == ['[bot] Help info']
class TestResponseWrapperPlugin:
@@ -223,6 +218,7 @@ class TestResponseWrapperPlugin:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert [str(chain) for chain in query.resp_message_chain] == ['Plugin response']
class TestResponseWrapperAssistant:
@@ -316,8 +312,11 @@ class TestResponseWrapperAssistant:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Event should have been emitted
app.plugin_connector.emit_event.assert_called()
assert [str(chain) for chain in query.resp_message_chain] == ['Hello back!']
app.plugin_connector.emit_event.assert_awaited_once()
event = app.plugin_connector.emit_event.await_args.args[0]
assert event.response_text == 'Hello back!'
assert event.funcs_called == []
@pytest.mark.asyncio
async def test_assistant_empty_content(self):
@@ -386,24 +385,36 @@ class TestResponseWrapperAssistant:
mock_tool_call = Mock()
mock_tool_call.function = Mock()
mock_tool_call.function.name = 'test_function'
other_tool_call = Mock()
other_tool_call.function.name = 'other_function'
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = 'Processing...'
assistant_resp.tool_calls = [mock_tool_call]
assistant_resp.tool_calls = [mock_tool_call, other_tool_call]
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')])
)
query.resp_messages = [assistant_resp]
results = []
delivered = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
delivered.append(str(query.resp_message_chain[-1]))
assert len(results) == 2
for result in results:
assert result.result_type == entities.ResultType.CONTINUE
assert app.plugin_connector.emit_event.await_count == 2
expected = ['Processing...', 'Calling test_function, other_function...']
assert delivered == expected
events = [sent.args[0] for sent in app.plugin_connector.emit_event.await_args_list]
assert [event.response_text for event in events] == expected
assert [event.funcs_called for event in events] == [
['test_function', 'other_function'],
['test_function', 'other_function'],
]
class TestResponseWrapperInterrupt:
@@ -1048,10 +1048,8 @@ class TestInvokeRerank:
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
)
assert len(results) == 3
# Scores should be normalized
assert results[0]['index'] == 0
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
assert [result['index'] for result in results] == [0, 1, 2]
assert [result['relevance_score'] for result in results] == pytest.approx([1.0, 0.0, 0.76923076923])
@pytest.mark.asyncio
async def test_invoke_rerank_normalization(self):
@@ -1297,8 +1295,8 @@ class TestConvertMessages:
result = requester._convert_messages(messages)
assert len(result) == 1
# LiteLLM handles multiple text parts, we pass them through
assert isinstance(result[0]['content'], list)
assert result[0]['role'] == 'user'
assert result[0]['content'] == [{'type': 'text', 'text': 'Hello'}, {'type': 'text', 'text': 'World'}]
class TestScanModels:
@@ -56,11 +56,8 @@ def test_requester_base_class_is_abstract():
mock_app = SimpleNamespace()
mock_app.logger = Mock()
# ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation
# if you don't call the abstract methods. Test that it has abstract methods.
assert hasattr(requester.ProviderAPIRequester, 'invoke_llm')
# Check that invoke_llm is abstract
assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__')
with pytest.raises(TypeError, match='abstract'):
requester.ProviderAPIRequester(mock_app, {})
def test_requester_default_config_merged():
@@ -250,16 +247,6 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data
assert isinstance(provider.requester, requester.ProviderAPIRequester)
def test_runtime_provider_has_invoke_methods(runtime_provider):
"""Test RuntimeProvider has invoke methods that delegate to requester."""
provider = runtime_provider
assert hasattr(provider, 'invoke_llm')
assert hasattr(provider, 'invoke_llm_stream')
assert hasattr(provider, 'invoke_embedding')
assert hasattr(provider, 'invoke_rerank')
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model):
"""Test RuntimeProvider.invoke_llm delegates to requester."""
@@ -404,6 +391,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
assert len(chunks) == 1
assert chunks[0].role == 'assistant'
assert [(part.type, part.text) for part in chunks[0].content] == [('text', 'Fake stream chunk')]
@pytest.mark.asyncio
@@ -513,13 +501,11 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da
assert model.reasoning_config_override is None
def test_runtime_llm_model_provider_ref(runtime_llm_model):
def test_runtime_llm_model_provider_ref(runtime_llm_model, runtime_provider):
"""Test RuntimeLLMModel has correct provider reference."""
model = runtime_llm_model
assert model.provider.provider_entity is not None
assert model.provider.token_mgr is not None
assert model.provider.requester is not None
assert model.provider is runtime_provider
# ============================================================================
@@ -365,7 +365,10 @@ class TestSessionManagerGetConversation:
)
assert conversation.prompt.name == 'default'
assert len(conversation.prompt.messages) == 2
assert [(message.role, message.content) for message in conversation.prompt.messages] == [
('system', 'System message'),
('user', 'User message'),
]
class TestSessionManagerWorkspaceIsolation:
+1 -3
View File
@@ -198,6 +198,4 @@ class TestSupportedOpsConstant:
def test_supported_ops_is_frozenset(self):
"""SUPPORTED_OPS is a frozenset for immutability."""
from collections.abc import Set
assert isinstance(SUPPORTED_OPS, Set)
assert isinstance(SUPPORTED_OPS, frozenset)