mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(pipeline): apply returned plugin event contexts
This commit is contained in:
@@ -229,6 +229,7 @@ async def test_remove_pipeline(mock_app):
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
"""Test runtime pipeline execution with real Pydantic models."""
|
||||
sample_query.query_id = 1
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
stage = get_stage_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
@@ -266,18 +267,59 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
)
|
||||
|
||||
# Mock plugin connector
|
||||
event_ctx = Mock()
|
||||
event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
|
||||
from langbot_plugin.api.entities.context import EventContext
|
||||
|
||||
async def return_event_context(event, bound_plugins):
|
||||
return EventContext.model_validate(EventContext.from_event(event).model_dump())
|
||||
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(side_effect=return_event_context)
|
||||
|
||||
# Execute pipeline
|
||||
await runtime_pipeline.run(sample_query)
|
||||
|
||||
# Verify stage was called
|
||||
mock_stage.process.assert_called_once()
|
||||
assert mock_stage.process.call_count == 1, mock_app.logger.error.call_args_list
|
||||
mock_app.query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_event_edits_reach_pipeline_stages(mock_app, sample_query):
|
||||
"""Read edits from the returned RPC context before running message stages."""
|
||||
from langbot_plugin.api.entities.context import EventContext
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
|
||||
sample_query.query_id = 1
|
||||
pipeline_entity = SimpleNamespace(
|
||||
name='Compatibility test',
|
||||
uuid='test-pipeline-uuid',
|
||||
workspace_uuid='test-workspace',
|
||||
config=sample_query.pipeline_config,
|
||||
extensions_preferences={'plugins': []},
|
||||
)
|
||||
runtime_pipeline = get_pipelinemgr_module().RuntimePipeline(
|
||||
mock_app,
|
||||
pipeline_entity,
|
||||
[],
|
||||
_context('test-pipeline-uuid'),
|
||||
)
|
||||
observed = []
|
||||
|
||||
async def plugin_edit(event, bound_plugins):
|
||||
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
|
||||
ctx.event.message_chain = MessageChain([Plain(text='edited by plugin')])
|
||||
return ctx
|
||||
|
||||
async def capture_stage(index, query):
|
||||
observed.append((str(query.message_chain), str(query.message_event.message_chain)))
|
||||
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(side_effect=plugin_edit)
|
||||
runtime_pipeline._execute_from_stage = AsyncMock(side_effect=capture_stage)
|
||||
|
||||
await runtime_pipeline.run(sample_query)
|
||||
|
||||
assert observed == [('edited by plugin', 'edited by plugin')], mock_app.logger.error.call_args_list
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_rejects_stale_generation_before_side_effects(
|
||||
mock_app,
|
||||
|
||||
@@ -76,6 +76,41 @@ def make_session(
|
||||
class TestPreProcessorNormalText:
|
||||
"""Tests for normal text message preprocessing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returned_plugin_prompt_edits_are_applied(self):
|
||||
"""Prompt hooks retain both edits across the serialized Runtime boundary."""
|
||||
from langbot_plugin.api.entities.context import EventContext
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
app = FakeApp()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
|
||||
conversation = Mock()
|
||||
conversation.prompt = Mock(messages=[])
|
||||
conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
conversation.messages = []
|
||||
conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
|
||||
model = Mock()
|
||||
model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model)
|
||||
observed_hooks = []
|
||||
|
||||
async def edit_prompts(event, bound_plugins):
|
||||
observed_hooks.append(event.event_name)
|
||||
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
|
||||
ctx.event.default_prompt = [Message(role='system', content='plugin system prompt')]
|
||||
ctx.event.prompt = [Message(role='assistant', content='plugin history')]
|
||||
return ctx
|
||||
|
||||
app.plugin_connector.emit_event = AsyncMock(side_effect=edit_prompts)
|
||||
query = text_query('hello')
|
||||
|
||||
await get_preproc_module().PreProcessor(app).process(query, 'PreProcessor')
|
||||
|
||||
assert observed_hooks == ['PromptPreProcessing']
|
||||
assert query.prompt.messages[0].content == 'plugin system prompt'
|
||||
assert query.messages[0].content == 'plugin history'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_text_continues(self):
|
||||
"""Normal text message should continue pipeline."""
|
||||
|
||||
@@ -228,6 +228,49 @@ class TestResponseWrapperPlugin:
|
||||
class TestResponseWrapperAssistant:
|
||||
"""Tests for assistant response wrapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('streaming', [False, True])
|
||||
@pytest.mark.parametrize('prevent_default', [False, True])
|
||||
async def test_returned_response_context_controls_delivery(self, streaming, prevent_default):
|
||||
"""Legacy response hooks can replace or block the actual outgoing chain."""
|
||||
from langbot_plugin.api.entities.context import EventContext
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
|
||||
app = FakeApp()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
|
||||
observed_hooks = []
|
||||
|
||||
async def edit_response(event, bound_plugins):
|
||||
observed_hooks.append(event.event_name)
|
||||
ctx = EventContext.model_validate(EventContext.from_event(event).model_dump())
|
||||
ctx.event.reply_message_chain = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='plugin reply'),
|
||||
]
|
||||
)
|
||||
if prevent_default:
|
||||
ctx.prevent_default()
|
||||
return ctx
|
||||
|
||||
app.plugin_connector.emit_event = AsyncMock(side_effect=edit_response)
|
||||
stage = get_wrapper_module().ResponseWrapper(app)
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_wrapper_config()
|
||||
message_class = MessageChunk if streaming else Message
|
||||
query.resp_messages = [message_class(role='assistant', content='model reply')]
|
||||
query.resp_message_chain = []
|
||||
await stage.initialize(query.pipeline_config)
|
||||
|
||||
results = [result async for result in stage.process(query, 'ResponseWrapper')]
|
||||
|
||||
assert observed_hooks == ['NormalMessageResponded']
|
||||
if prevent_default:
|
||||
assert results[0].result_type == get_entities_module().ResultType.INTERRUPT
|
||||
assert query.resp_message_chain == []
|
||||
else:
|
||||
assert results[0].result_type == get_entities_module().ResultType.CONTINUE
|
||||
assert [str(chain) for chain in query.resp_message_chain] == ['plugin reply']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_content_response(self):
|
||||
"""Assistant with content should emit event and wrap."""
|
||||
|
||||
@@ -239,6 +239,43 @@ class TestListPlugins:
|
||||
|
||||
|
||||
class TestPluginDiagnostics:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prevent_postorder_stops_later_installations_and_restores_query(self):
|
||||
from langbot_plugin.api.entities.events import PersonMessageReceived
|
||||
|
||||
connector = create_mock_connector()
|
||||
query = text_query('hello')
|
||||
event = PersonMessageReceived(
|
||||
query=query,
|
||||
launcher_type=query.launcher_type.value,
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
message_event=query.message_event,
|
||||
message_chain=query.message_chain,
|
||||
)
|
||||
second_binding = TEST_INSTALLATION_BINDING.model_copy(
|
||||
update={
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000002',
|
||||
}
|
||||
)
|
||||
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING, second_binding])
|
||||
|
||||
async def stop_following_plugins(event_context, include_plugins=None):
|
||||
event_context['is_prevent_postorder'] = True
|
||||
return {'event_context': event_context, 'emitted_plugins': ['first']}
|
||||
|
||||
runtime_handler = configure_handler(connector, Mock())
|
||||
runtime_handler.emit_event = AsyncMock(side_effect=stop_following_plugins)
|
||||
|
||||
returned = await connector.emit_event(event)
|
||||
|
||||
runtime_handler.emit_event.assert_awaited_once()
|
||||
assert returned.is_prevented_postorder()
|
||||
assert not returned.is_prevented_default()
|
||||
assert returned.event.query is query
|
||||
assert 'query' not in returned.event.model_dump()
|
||||
assert returned._emitted_plugins == ['first']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_event_preserves_response_sources(self):
|
||||
connector = create_mock_connector()
|
||||
|
||||
Reference in New Issue
Block a user