mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
@@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
# this, running a stage test in isolation triggers a circular-import error:
|
||||
# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound).
|
||||
import langbot.pkg.pipeline.pipelinemgr # noqa: F401
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
@@ -43,6 +44,14 @@ class MockApplication:
|
||||
self.query_pool = self._create_mock_query_pool()
|
||||
self.instance_config = self._create_mock_instance_config()
|
||||
self.task_mgr = self._create_mock_task_manager()
|
||||
self.workspace_service = AsyncMock()
|
||||
self.workspace_service.get_execution_binding = AsyncMock(
|
||||
return_value=Mock(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
# Skill manager is optional; PreProcessor only touches it for the
|
||||
# local-agent runner. None keeps the skill-binding branch inert.
|
||||
self.skill_mgr = None
|
||||
@@ -86,6 +95,7 @@ class MockApplication:
|
||||
query_pool.cached_queries = {}
|
||||
query_pool.queries = []
|
||||
query_pool.condition = AsyncMock()
|
||||
query_pool.remove_query = AsyncMock(return_value=True)
|
||||
return query_pool
|
||||
|
||||
def _create_mock_instance_config(self):
|
||||
@@ -194,6 +204,9 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
|
||||
|
||||
# Use model_construct to bypass Pydantic validation for test purposes
|
||||
query = pipeline_query.Query.model_construct(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
query_id='test-query-id',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -227,6 +240,17 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
|
||||
resp_message_chain=None,
|
||||
current_stage_name=None,
|
||||
)
|
||||
object.__setattr__(
|
||||
query,
|
||||
'_execution_context',
|
||||
ExecutionContext(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
bot_uuid='test-bot-uuid',
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
),
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import contextvars
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
@@ -25,6 +28,49 @@ from tests.factories import (
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.pipeline.pool import (
|
||||
ExecutionContextMismatchError,
|
||||
ExecutionContextRequiredError,
|
||||
bind_execution_context,
|
||||
)
|
||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||
|
||||
|
||||
def execution_context(
|
||||
workspace_uuid='workspace-test',
|
||||
*,
|
||||
bot_uuid='test-bot',
|
||||
pipeline_uuid=None,
|
||||
placement_generation=1,
|
||||
):
|
||||
return ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=placement_generation,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
|
||||
|
||||
def aggregation_key(
|
||||
context,
|
||||
*,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
bot_uuid='test-bot',
|
||||
pipeline_uuid=None,
|
||||
):
|
||||
return (
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
context.placement_generation,
|
||||
bot_uuid,
|
||||
pipeline_uuid,
|
||||
launcher_type.value,
|
||||
launcher_id,
|
||||
)
|
||||
|
||||
|
||||
def get_aggregator_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
@@ -36,12 +82,66 @@ def make_aggregator_app():
|
||||
app = FakeApp()
|
||||
# Ensure query_pool has add_query method
|
||||
app.query_pool.add_query = AsyncMock()
|
||||
|
||||
async def resolve_context(
|
||||
context,
|
||||
*,
|
||||
bot_uuid,
|
||||
pipeline_uuid,
|
||||
query_uuid=None,
|
||||
):
|
||||
if context is None:
|
||||
raise ExecutionContextRequiredError('ExecutionContext required in test')
|
||||
return bind_execution_context(
|
||||
context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
)
|
||||
|
||||
app.query_pool.resolve_execution_context = AsyncMock(side_effect=resolve_context)
|
||||
# Add pipeline_mgr mock
|
||||
app.pipeline_mgr = AsyncMock()
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
|
||||
app.workspace_service = Mock()
|
||||
app.workspace_service.get_execution_binding = AsyncMock(
|
||||
return_value=Mock(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def enable_aggregation(app, *, delay=10.0):
|
||||
pipeline = Mock()
|
||||
pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': delay,
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=pipeline)
|
||||
|
||||
|
||||
def scoped_message_kwargs(context, *, launcher_id=12345, text='hello'):
|
||||
chain = text_chain(text)
|
||||
return {
|
||||
'execution_context': context,
|
||||
'bot_uuid': context.bot_uuid,
|
||||
'launcher_type': provider_session.LauncherTypes.PERSON,
|
||||
'launcher_id': launcher_id,
|
||||
'sender_id': launcher_id,
|
||||
'message_event': friend_message_event(chain),
|
||||
'message_chain': chain,
|
||||
'adapter': mock_adapter(),
|
||||
'pipeline_uuid': context.pipeline_uuid,
|
||||
}
|
||||
|
||||
|
||||
class TestPendingMessage:
|
||||
"""Tests for PendingMessage dataclass."""
|
||||
|
||||
@@ -54,6 +154,7 @@ class TestPendingMessage:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -77,9 +178,14 @@ class TestSessionBuffer:
|
||||
"""SessionBuffer should be created with correct fields."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
buffer = aggregator.SessionBuffer(session_id='test-session')
|
||||
context = execution_context()
|
||||
key = aggregation_key(context)
|
||||
buffer = aggregator.SessionBuffer(
|
||||
aggregation_key=key,
|
||||
execution_context=context,
|
||||
)
|
||||
|
||||
assert buffer.session_id == 'test-session'
|
||||
assert buffer.aggregation_key == key
|
||||
assert buffer.messages == []
|
||||
assert buffer.timer_task is None
|
||||
assert buffer.last_message_time is not None
|
||||
@@ -93,6 +199,7 @@ class TestSessionBuffer:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -103,8 +210,10 @@ class TestSessionBuffer:
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
context = execution_context()
|
||||
buffer = aggregator.SessionBuffer(
|
||||
session_id='test-session',
|
||||
aggregation_key=aggregation_key(context),
|
||||
execution_context=context,
|
||||
messages=[pending],
|
||||
)
|
||||
|
||||
@@ -127,7 +236,7 @@ class TestMessageAggregatorInit:
|
||||
|
||||
|
||||
class TestMessageAggregatorSessionId:
|
||||
"""Tests for session ID generation."""
|
||||
"""Tests for scoped aggregation key generation."""
|
||||
|
||||
def test_session_id_format(self):
|
||||
"""Session ID should be correctly formatted."""
|
||||
@@ -136,13 +245,24 @@ class TestMessageAggregatorSessionId:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
session_id = agg._get_session_id(
|
||||
context = execution_context()
|
||||
session_id = agg._get_aggregation_key(
|
||||
context,
|
||||
bot_uuid='bot-123',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=45678,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
assert session_id == 'bot-123:person:45678'
|
||||
assert session_id == (
|
||||
'instance-test',
|
||||
'workspace-test',
|
||||
1,
|
||||
'bot-123',
|
||||
None,
|
||||
'person',
|
||||
45678,
|
||||
)
|
||||
|
||||
def test_session_id_different_launchers(self):
|
||||
"""Different launcher types should produce different IDs."""
|
||||
@@ -151,16 +271,21 @@ class TestMessageAggregatorSessionId:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
person_id = agg._get_session_id(
|
||||
context = execution_context()
|
||||
person_id = agg._get_aggregation_key(
|
||||
context,
|
||||
bot_uuid='bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=123,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
group_id = agg._get_session_id(
|
||||
group_id = agg._get_aggregation_key(
|
||||
context,
|
||||
bot_uuid='bot',
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=123,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
assert person_id != group_id
|
||||
@@ -177,7 +302,7 @@ class TestMessageAggregatorConfig:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config(None)
|
||||
enabled, delay = await agg._get_aggregation_config(execution_context(), None)
|
||||
|
||||
assert enabled == False
|
||||
assert delay == 1.5
|
||||
@@ -191,7 +316,10 @@ class TestMessageAggregatorConfig:
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('unknown-pipeline')
|
||||
enabled, delay = await agg._get_aggregation_config(
|
||||
execution_context(pipeline_uuid='unknown-pipeline'),
|
||||
'unknown-pipeline',
|
||||
)
|
||||
|
||||
assert enabled == False
|
||||
assert delay == 1.5
|
||||
@@ -217,7 +345,10 @@ class TestMessageAggregatorConfig:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
enabled, delay = await agg._get_aggregation_config(
|
||||
execution_context(pipeline_uuid='test-pipeline'),
|
||||
'test-pipeline',
|
||||
)
|
||||
|
||||
assert enabled == True
|
||||
assert delay == 2.0
|
||||
@@ -243,7 +374,10 @@ class TestMessageAggregatorConfig:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
enabled, delay = await agg._get_aggregation_config(
|
||||
execution_context(pipeline_uuid='test-pipeline'),
|
||||
'test-pipeline',
|
||||
)
|
||||
|
||||
assert delay == 1.0 # Clamped to minimum
|
||||
|
||||
@@ -268,7 +402,10 @@ class TestMessageAggregatorConfig:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
enabled, delay = await agg._get_aggregation_config(
|
||||
execution_context(pipeline_uuid='test-pipeline'),
|
||||
'test-pipeline',
|
||||
)
|
||||
|
||||
assert delay == 10.0 # Clamped to maximum
|
||||
|
||||
@@ -293,7 +430,10 @@ class TestMessageAggregatorConfig:
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
enabled, delay = await agg._get_aggregation_config(
|
||||
execution_context(pipeline_uuid='test-pipeline'),
|
||||
'test-pipeline',
|
||||
)
|
||||
|
||||
assert delay == 1.5 # Default
|
||||
|
||||
@@ -314,6 +454,7 @@ class TestMessageAggregatorAddMessage:
|
||||
adapter = mock_adapter()
|
||||
|
||||
await agg.add_message(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -353,6 +494,7 @@ class TestMessageAggregatorAddMessage:
|
||||
adapter = mock_adapter()
|
||||
|
||||
await agg.add_message(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -379,6 +521,7 @@ class TestMessageAggregatorAddMessage:
|
||||
|
||||
chain = text_chain('approve')
|
||||
await agg.add_message(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -424,6 +567,7 @@ class TestMessageAggregatorAddMessage:
|
||||
# Add messages up to MAX_BUFFER_MESSAGES
|
||||
for i in range(aggregator.MAX_BUFFER_MESSAGES):
|
||||
await agg.add_message(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -435,7 +579,14 @@ class TestMessageAggregatorAddMessage:
|
||||
)
|
||||
|
||||
# Buffer should be flushed (empty or no buffer)
|
||||
session_id = agg._get_session_id('test-bot', provider_session.LauncherTypes.PERSON, 12345)
|
||||
context = execution_context(pipeline_uuid='test-pipeline')
|
||||
session_id = agg._get_aggregation_key(
|
||||
context,
|
||||
'test-bot',
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
12345,
|
||||
'test-pipeline',
|
||||
)
|
||||
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
|
||||
|
||||
|
||||
@@ -454,6 +605,7 @@ class TestMessageAggregatorMerge:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -481,6 +633,7 @@ class TestMessageAggregatorMerge:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -492,6 +645,7 @@ class TestMessageAggregatorMerge:
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -522,6 +676,7 @@ class TestMessageAggregatorMerge:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -534,6 +689,7 @@ class TestMessageAggregatorMerge:
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -562,7 +718,8 @@ class TestMessageAggregatorFlush:
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
await agg._flush_buffer('nonexistent-session')
|
||||
context = execution_context()
|
||||
await agg._flush_buffer(aggregation_key(context), context)
|
||||
|
||||
# Should not call query_pool
|
||||
assert not app.query_pool.add_query.called
|
||||
@@ -580,6 +737,7 @@ class TestMessageAggregatorFlush:
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -590,17 +748,57 @@ class TestMessageAggregatorFlush:
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
context = execution_context()
|
||||
key = aggregation_key(context)
|
||||
buffer = aggregator.SessionBuffer(
|
||||
session_id='test-session',
|
||||
aggregation_key=key,
|
||||
execution_context=context,
|
||||
messages=[pending],
|
||||
)
|
||||
|
||||
agg.buffers['test-session'] = buffer
|
||||
agg.buffers[key] = buffer
|
||||
|
||||
await agg._flush_buffer('test-session')
|
||||
await agg._flush_buffer(key, context)
|
||||
|
||||
assert app.query_pool.add_query.called
|
||||
assert 'test-session' not in agg.buffers
|
||||
assert key not in agg.buffers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_drops_buffer_when_placement_generation_is_stale(self):
|
||||
"""A debounce timer cannot enqueue work after its placement is fenced."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
context = execution_context(placement_generation=3)
|
||||
pending = aggregator.PendingMessage(
|
||||
execution_context=context,
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=friend_message_event(text_chain('stale')),
|
||||
message_chain=text_chain('stale'),
|
||||
adapter=mock_adapter(),
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
key = aggregation_key(context)
|
||||
agg.buffers[key] = aggregator.SessionBuffer(
|
||||
aggregation_key=key,
|
||||
execution_context=context,
|
||||
messages=[pending],
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceGenerationMismatchError):
|
||||
await agg._flush_buffer(key, context)
|
||||
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'workspace-test',
|
||||
expected_generation=3,
|
||||
)
|
||||
app.query_pool.add_query.assert_not_awaited()
|
||||
assert key not in agg.buffers
|
||||
|
||||
|
||||
class TestMessageAggregatorFlushAll:
|
||||
@@ -633,6 +831,7 @@ class TestMessageAggregatorFlushAll:
|
||||
|
||||
# Create two buffers
|
||||
pending1 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
@@ -644,6 +843,7 @@ class TestMessageAggregatorFlushAll:
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
execution_context=execution_context(),
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=67890,
|
||||
@@ -654,14 +854,213 @@ class TestMessageAggregatorFlushAll:
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
buffer1 = aggregator.SessionBuffer(session_id='session-1', messages=[pending1])
|
||||
buffer2 = aggregator.SessionBuffer(session_id='session-2', messages=[pending2])
|
||||
context = execution_context()
|
||||
key1 = aggregation_key(context, launcher_id=12345)
|
||||
key2 = aggregation_key(context, launcher_id=67890)
|
||||
buffer1 = aggregator.SessionBuffer(
|
||||
aggregation_key=key1,
|
||||
execution_context=context,
|
||||
messages=[pending1],
|
||||
)
|
||||
buffer2 = aggregator.SessionBuffer(
|
||||
aggregation_key=key2,
|
||||
execution_context=context,
|
||||
messages=[pending2],
|
||||
)
|
||||
|
||||
agg.buffers['session-1'] = buffer1
|
||||
agg.buffers['session-2'] = buffer2
|
||||
agg.buffers[key1] = buffer1
|
||||
agg.buffers[key2] = buffer2
|
||||
|
||||
await agg.flush_all()
|
||||
|
||||
# Both buffers should be flushed
|
||||
assert len(agg.buffers) == 0
|
||||
assert app.query_pool.add_query.call_count == 2
|
||||
|
||||
|
||||
class TestMessageAggregatorWorkspaceIsolation:
|
||||
"""Regression coverage for fail-closed and cross-workspace behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_execution_context_fails_closed(self):
|
||||
app = make_aggregator_app()
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
kwargs = scoped_message_kwargs(execution_context())
|
||||
kwargs['execution_context'] = None
|
||||
|
||||
with pytest.raises(ExecutionContextRequiredError):
|
||||
await agg.add_message(**kwargs)
|
||||
|
||||
app.query_pool.add_query.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_launcher_in_two_workspaces_uses_separate_buffers(self):
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
|
||||
await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
|
||||
await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))
|
||||
|
||||
assert len(agg.buffers) == 2
|
||||
assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'}
|
||||
await agg.flush_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_buffer_uses_scope_counter_without_global_scan(self):
|
||||
class NoGlobalIterationDict(dict):
|
||||
def __iter__(self):
|
||||
raise AssertionError('aggregation admission scanned all buffers')
|
||||
|
||||
def items(self):
|
||||
raise AssertionError('aggregation admission scanned all buffers')
|
||||
|
||||
def values(self):
|
||||
raise AssertionError('aggregation admission scanned all buffers')
|
||||
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
agg.max_buffers = 2_000
|
||||
agg.max_buffers_per_workspace = 2_000
|
||||
existing = {
|
||||
(
|
||||
'instance-test',
|
||||
f'workspace-{index}',
|
||||
1,
|
||||
'bot',
|
||||
'pipeline',
|
||||
'person',
|
||||
index,
|
||||
): object()
|
||||
for index in range(1_000)
|
||||
}
|
||||
agg.buffers = NoGlobalIterationDict(existing)
|
||||
agg._buffer_counts_by_scope = {key[:3]: 1 for key in existing}
|
||||
context = execution_context(
|
||||
'workspace-target',
|
||||
pipeline_uuid='test-pipeline',
|
||||
)
|
||||
|
||||
await agg.add_message(**scoped_message_kwargs(context))
|
||||
|
||||
key = aggregation_key(
|
||||
context,
|
||||
pipeline_uuid='test-pipeline',
|
||||
)
|
||||
assert key in agg.buffers
|
||||
assert agg._buffer_counts_by_scope[key[:3]] == 1
|
||||
timer_task = agg.buffers[key].timer_task
|
||||
assert timer_task is not None
|
||||
timer_task.cancel()
|
||||
await asyncio.gather(timer_task, return_exceptions=True)
|
||||
await agg._flush_buffer(key, context)
|
||||
assert key[:3] not in agg._buffer_counts_by_scope
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_launcher_in_two_bots_uses_separate_buffers(self):
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
|
||||
await agg.add_message(
|
||||
**scoped_message_kwargs(execution_context(bot_uuid='bot-a', pipeline_uuid='test-pipeline'))
|
||||
)
|
||||
await agg.add_message(
|
||||
**scoped_message_kwargs(execution_context(bot_uuid='bot-b', pipeline_uuid='test-pipeline'))
|
||||
)
|
||||
|
||||
assert len(agg.buffers) == 2
|
||||
assert {key[3] for key in agg.buffers} == {'bot-a', 'bot-b'}
|
||||
await agg.flush_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timer_receives_exact_captured_execution_context(self, monkeypatch):
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
request_value = contextvars.ContextVar('aggregator_request_value', default=None)
|
||||
token = request_value.set('request-scope')
|
||||
observed = []
|
||||
|
||||
async def delayed_flush(*args):
|
||||
observed.append((request_value.get(), args[2]))
|
||||
|
||||
monkeypatch.setattr(agg, '_delayed_flush', delayed_flush)
|
||||
context = execution_context(pipeline_uuid='test-pipeline')
|
||||
|
||||
try:
|
||||
await agg.add_message(**scoped_message_kwargs(context))
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
request_value.reset(token)
|
||||
|
||||
assert observed == [(None, context)]
|
||||
await agg.flush_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_flush_opens_explicit_workspace_uow(self, monkeypatch):
|
||||
app = make_aggregator_app()
|
||||
app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||
scopes = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(workspace_uuid):
|
||||
scopes.append(workspace_uuid)
|
||||
yield
|
||||
|
||||
app.persistence_mgr.tenant_uow = tenant_uow
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
flush = AsyncMock()
|
||||
monkeypatch.setattr(agg, '_flush_buffer', flush)
|
||||
context = execution_context('workspace-a', pipeline_uuid='test-pipeline')
|
||||
key = aggregation_key(context, pipeline_uuid='test-pipeline')
|
||||
|
||||
await agg._delayed_flush(key, 0, context)
|
||||
|
||||
assert scopes == ['workspace-a']
|
||||
flush.assert_awaited_once_with(key, context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_rejects_context_from_another_workspace(self):
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
context_a = execution_context('workspace-a', pipeline_uuid='test-pipeline')
|
||||
context_b = execution_context('workspace-b', pipeline_uuid='test-pipeline')
|
||||
await agg.add_message(**scoped_message_kwargs(context_a))
|
||||
key = next(iter(agg.buffers))
|
||||
|
||||
with pytest.raises(ExecutionContextMismatchError):
|
||||
await agg._flush_buffer(key, context_b)
|
||||
|
||||
assert key in agg.buffers
|
||||
await agg.flush_all()
|
||||
|
||||
def test_merge_rejects_messages_from_different_workspaces(self):
|
||||
app = make_aggregator_app()
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
with pytest.raises(ExecutionContextMismatchError):
|
||||
agg._merge_messages(
|
||||
[
|
||||
aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-a'))),
|
||||
aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-b'))),
|
||||
]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_all_preserves_each_workspace_context(self):
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
|
||||
await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))
|
||||
|
||||
await agg.flush_all()
|
||||
|
||||
forwarded_workspaces = {
|
||||
call.kwargs['execution_context'].workspace_uuid for call in app.query_pool.add_query.await_args_list
|
||||
}
|
||||
assert forwarded_workspaces == {'workspace-a', 'workspace-b'}
|
||||
|
||||
@@ -486,3 +486,13 @@ class TestChatHandlerHelper:
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
result = handler.cut_str('first line\nsecond line')
|
||||
assert '...' in result
|
||||
|
||||
def test_response_size_limit_uses_instance_config(self, fake_app):
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
fake_app.instance_config.data['system'] = {'response_limits': {'max_generated_chars': 4}}
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
with pytest.raises(RuntimeError, match='configured limit'):
|
||||
handler._check_response_size(Message(role='assistant', content='12345'))
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
|
||||
def _preproc_module():
|
||||
@@ -43,7 +44,12 @@ def _prompt_preprocessing_context(default_prompt=None, prompt=None):
|
||||
|
||||
|
||||
async def _run_preprocessor(mock_app, sample_query, conversation):
|
||||
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
|
||||
session = provider_session.Session(
|
||||
launcher_type=sample_query.launcher_type,
|
||||
launcher_id=sample_query.launcher_id,
|
||||
sender_id=sample_query.sender_id,
|
||||
bot_uuid=sample_query.bot_uuid,
|
||||
)
|
||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
|
||||
|
||||
@@ -158,17 +158,21 @@ class TestCommandHandlerReal:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Admin users get privilege level 2."""
|
||||
"""A per-bot admin from the database is marked as admin in command events."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = Mock()
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
@@ -176,23 +180,28 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Non-admin users get privilege level 1."""
|
||||
"""A launcher absent from the per-bot admin table is not an admin."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = None
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 67890
|
||||
|
||||
@@ -200,6 +209,7 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is False
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.agent.runner.errors import RunnerNotFoundError
|
||||
from langbot.pkg.pipeline.controller import Controller
|
||||
|
||||
@@ -30,6 +31,21 @@ def make_pipeline():
|
||||
)
|
||||
|
||||
|
||||
def make_query(query_id: int, pipeline_uuid: str):
|
||||
context = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
query_id=query_id,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
variables={},
|
||||
_execution_context=context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_try_claim_steering_returns_false_when_runner_lookup_fails():
|
||||
app = make_app()
|
||||
@@ -38,7 +54,7 @@ async def test_try_claim_steering_returns_false_when_runner_lookup_fails():
|
||||
'plugin:missing/runner/default'
|
||||
)
|
||||
controller = Controller(app)
|
||||
query = SimpleNamespace(query_id=1, pipeline_uuid='pipeline-001', variables={})
|
||||
query = make_query(1, 'pipeline-001')
|
||||
|
||||
claimed = await controller._try_claim_steering_before_session_slot(query)
|
||||
|
||||
@@ -53,7 +69,7 @@ async def test_try_claim_steering_sets_pipeline_context_before_claiming():
|
||||
app.pipeline_mgr.get_pipeline_by_uuid.return_value = pipeline
|
||||
app.agent_run_orchestrator.try_claim_steering_from_query.return_value = True
|
||||
controller = Controller(app)
|
||||
query = SimpleNamespace(query_id=2, pipeline_uuid='pipeline-002', variables={})
|
||||
query = make_query(2, 'pipeline-002')
|
||||
|
||||
claimed = await controller._try_claim_steering_before_session_slot(query)
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||
from langbot.pkg.pipeline.controller import Controller
|
||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||
|
||||
|
||||
def _prepare_scheduler(mock_app):
|
||||
query_pool = MagicMock()
|
||||
query_pool.remove_query = AsyncMock(return_value=True)
|
||||
query_pool.__aenter__ = AsyncMock(return_value=query_pool)
|
||||
query_pool.__aexit__ = AsyncMock(return_value=None)
|
||||
query_pool.condition = SimpleNamespace(notify_all=Mock())
|
||||
mock_app.query_pool = query_pool
|
||||
|
||||
session = SimpleNamespace(_semaphore=SimpleNamespace(release=Mock()))
|
||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
mock_app.pipeline_mgr = SimpleNamespace(get_pipeline_by_uuid=AsyncMock())
|
||||
return query_pool, session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_controller_drops_stale_query_before_pipeline_lookup(
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
query_pool, session = _prepare_scheduler(mock_app)
|
||||
mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
|
||||
controller = Controller(mock_app)
|
||||
initial_slots = controller.semaphore._value
|
||||
|
||||
await controller._process_query(sample_query)
|
||||
|
||||
mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'test-workspace',
|
||||
expected_generation=1,
|
||||
)
|
||||
mock_app.pipeline_mgr.get_pipeline_by_uuid.assert_not_awaited()
|
||||
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||
session._semaphore.release.assert_called_once_with()
|
||||
query_pool.condition.notify_all.assert_called_once_with()
|
||||
assert controller.semaphore._value == initial_slots
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_controller_releases_database_connection_during_pipeline_wait(
|
||||
tmp_path,
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "pipeline-short-scope.db"}')
|
||||
table = sa.Table('pipeline_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
checked_out = 0
|
||||
|
||||
def on_checkout(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out += 1
|
||||
|
||||
def on_checkin(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out -= 1
|
||||
|
||||
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
|
||||
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
_prepare_scheduler(mock_app)
|
||||
mock_app.persistence_mgr = manager
|
||||
pipeline_waiting = asyncio.Event()
|
||||
release_pipeline = asyncio.Event()
|
||||
|
||||
async def get_binding(*_args, **_kwargs):
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_session() is None
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
return SimpleNamespace(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
async def run_pipeline(_query):
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
pipeline_waiting.set()
|
||||
await release_pipeline.wait()
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_session() is None
|
||||
|
||||
runtime_pipeline = SimpleNamespace(run=AsyncMock(side_effect=run_pipeline))
|
||||
|
||||
async def get_pipeline(*_args, **_kwargs):
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
return runtime_pipeline
|
||||
|
||||
mock_app.workspace_service.get_execution_binding = AsyncMock(side_effect=get_binding)
|
||||
mock_app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(side_effect=get_pipeline)
|
||||
controller = Controller(mock_app)
|
||||
|
||||
task = asyncio.create_task(controller._process_query(sample_query))
|
||||
await asyncio.wait_for(pipeline_waiting.wait(), timeout=2)
|
||||
assert checked_out == 0
|
||||
assert not task.done()
|
||||
release_pipeline.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
assert checked_out == 0
|
||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_controller_revalidates_generation_before_running_pipeline(
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
query_pool, session = _prepare_scheduler(mock_app)
|
||||
runtime_pipeline = SimpleNamespace(run=AsyncMock())
|
||||
mock_app.pipeline_mgr.get_pipeline_by_uuid.return_value = runtime_pipeline
|
||||
controller = Controller(mock_app)
|
||||
|
||||
await controller._process_query(sample_query)
|
||||
|
||||
mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'test-workspace',
|
||||
expected_generation=1,
|
||||
)
|
||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||
session._semaphore.release.assert_called_once_with()
|
||||
@@ -0,0 +1,50 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.pipeline.longtext.strategies.image import Text2ImageStrategy
|
||||
from langbot.pkg.pipeline.longtext.strategies import image
|
||||
|
||||
|
||||
class _WideFont:
|
||||
def getlength(self, text: str) -> int:
|
||||
return len(text) * 100
|
||||
|
||||
|
||||
def test_image_strategy_line_split_always_consumes_input():
|
||||
strategy = Text2ImageStrategy(Mock())
|
||||
|
||||
lines = strategy._split_text_lines('abc', 1, _WideFont())
|
||||
|
||||
assert lines == ['a', 'b', 'c']
|
||||
assert ''.join(lines) == 'abc'
|
||||
|
||||
|
||||
def test_image_strategy_numeric_boundaries_are_found_in_linear_order():
|
||||
strategy = Text2ImageStrategy(Mock())
|
||||
|
||||
assert strategy.indexNumber('a12-b12-c345') == [['12', 1], ['12', 5], ['345', 9]]
|
||||
|
||||
|
||||
def test_image_strategy_rejects_unbounded_line_count_before_allocating_canvas(monkeypatch):
|
||||
strategy = Text2ImageStrategy(Mock())
|
||||
monkeypatch.setattr(image, '_MAX_TEXT_TO_IMAGE_LINES', 2)
|
||||
|
||||
with pytest.raises(ValueError, match='2 lines'):
|
||||
strategy._split_text_lines('one\ntwo\nthree', 1000, _WideFont())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_strategy_falls_back_to_forward_for_oversized_text(monkeypatch):
|
||||
app = Mock()
|
||||
strategy = Text2ImageStrategy(app)
|
||||
monkeypatch.setattr(image, '_MAX_TEXT_TO_IMAGE_CHARS', 4)
|
||||
query = SimpleNamespace(adapter=SimpleNamespace(bot_account_id='bot'))
|
||||
|
||||
components = await strategy.process('12345', query)
|
||||
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
app.logger.warning.assert_called_once()
|
||||
@@ -5,13 +5,19 @@ import pytest
|
||||
from langbot.pkg.api.http.service.pipeline import PipelineService
|
||||
|
||||
|
||||
WORKSPACE_UUID = 'workspace-a'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_filters_protected_fields_without_mutating_input(mock_app):
|
||||
service = PipelineService(mock_app)
|
||||
loaded_pipeline = Mock()
|
||||
service.get_pipeline = AsyncMock(return_value=loaded_pipeline)
|
||||
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=None)
|
||||
bot = Mock(uuid='bot-uuid')
|
||||
bot_result = Mock(all=Mock(return_value=[bot]))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=[None, bot_result])
|
||||
mock_app.bot_service = Mock(update_bot=AsyncMock())
|
||||
mock_app.pipeline_mgr = Mock(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock())
|
||||
mock_app.sess_mgr.session_list = []
|
||||
|
||||
@@ -24,7 +30,7 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
|
||||
}
|
||||
original_pipeline_data = pipeline_data.copy()
|
||||
|
||||
await service.update_pipeline('pipeline-uuid', pipeline_data)
|
||||
await service.update_pipeline(WORKSPACE_UUID, 'pipeline-uuid', pipeline_data)
|
||||
|
||||
assert pipeline_data == original_pipeline_data
|
||||
|
||||
@@ -32,5 +38,6 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
|
||||
updated_fields = {getattr(field, 'key', str(field)) for field in update_stmt._values}
|
||||
assert updated_fields == {'name'}
|
||||
|
||||
mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('pipeline-uuid')
|
||||
mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with(loaded_pipeline)
|
||||
mock_app.bot_service.update_bot.assert_not_awaited()
|
||||
mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('workspace-a', 'pipeline-uuid')
|
||||
mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with('workspace-a', loaded_pipeline)
|
||||
|
||||
@@ -3,9 +3,26 @@ PipelineManager unit tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
|
||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError, WorkspaceInvariantError
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
|
||||
def _context(pipeline_uuid: str = 'test-uuid') -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
|
||||
|
||||
def get_pipelinemgr_module():
|
||||
return import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
@@ -37,6 +54,95 @@ async def test_pipeline_manager_initialize(mock_app):
|
||||
assert len(manager.pipelines) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_startup_reuses_validated_pipeline_binding(mock_app):
|
||||
class TenantUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
binding = WorkspaceExecutionBinding(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
write_fenced=False,
|
||||
state='active',
|
||||
)
|
||||
pipeline_entity = Mock(
|
||||
uuid='test-uuid',
|
||||
workspace_uuid='test-workspace',
|
||||
stages=[],
|
||||
config={},
|
||||
extensions_preferences={},
|
||||
)
|
||||
mock_app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||
mock_app.persistence_mgr.tenant_uow = lambda _workspace_uuid: TenantUow()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[pipeline_entity])))
|
||||
mock_app.workspace_service.list_active_execution_bindings = AsyncMock(return_value=[binding])
|
||||
mock_app.workspace_service.get_execution_binding = AsyncMock(
|
||||
side_effect=AssertionError('startup pipeline loader repeated a validated binding lookup')
|
||||
)
|
||||
manager = get_pipelinemgr_module().PipelineManager(mock_app)
|
||||
manager.stage_dict = {}
|
||||
|
||||
await manager.load_pipelines_from_db()
|
||||
|
||||
assert len(manager.pipelines) == 1
|
||||
mock_app.workspace_service.get_execution_binding.assert_not_awaited()
|
||||
|
||||
|
||||
def test_generation_advance_prunes_superseded_workspace_pipelines(mock_app):
|
||||
class NoGlobalIterationDict(dict):
|
||||
def __iter__(self):
|
||||
raise AssertionError('generation advance scanned every pipeline')
|
||||
|
||||
def items(self):
|
||||
raise AssertionError('generation advance scanned every pipeline')
|
||||
|
||||
def values(self):
|
||||
raise AssertionError('generation advance scanned every pipeline')
|
||||
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
manager = pipelinemgr.PipelineManager(mock_app)
|
||||
old_context = _context()
|
||||
next_context = ExecutionContext(
|
||||
instance_uuid=old_context.instance_uuid,
|
||||
workspace_uuid=old_context.workspace_uuid,
|
||||
placement_generation=2,
|
||||
pipeline_uuid=old_context.pipeline_uuid,
|
||||
)
|
||||
old_pipeline = SimpleNamespace(
|
||||
execution_context=old_context,
|
||||
workspace_uuid=old_context.workspace_uuid,
|
||||
placement_generation=old_context.placement_generation,
|
||||
)
|
||||
other_pipelines = [
|
||||
SimpleNamespace(
|
||||
execution_context=ExecutionContext(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid=f'workspace-{index}',
|
||||
placement_generation=1,
|
||||
pipeline_uuid=f'pipeline-{index}',
|
||||
),
|
||||
workspace_uuid=f'workspace-{index}',
|
||||
placement_generation=1,
|
||||
)
|
||||
for index in range(1_000)
|
||||
]
|
||||
manager.pipelines = [old_pipeline, *other_pipelines]
|
||||
|
||||
manager._observe_execution_context(old_context)
|
||||
manager._pipelines_by_key = NoGlobalIterationDict(manager._pipelines_by_key)
|
||||
manager._observe_execution_context(next_context)
|
||||
manager._pipelines_by_key = dict(manager._pipelines_by_key)
|
||||
|
||||
assert manager.pipelines == other_pipelines
|
||||
with pytest.raises(WorkspaceInvariantError, match='rolled back'):
|
||||
manager._observe_execution_context(old_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_pipeline(mock_app):
|
||||
"""Test loading a single pipeline"""
|
||||
@@ -51,11 +157,12 @@ async def test_load_pipeline(mock_app):
|
||||
# Create test pipeline entity
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {'test': 'config'}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
await manager.load_pipeline(_context(), pipeline_entity)
|
||||
|
||||
assert len(manager.pipelines) == 1
|
||||
assert manager.pipelines[0].pipeline_entity.uuid == 'test-uuid'
|
||||
@@ -75,19 +182,20 @@ async def test_get_pipeline_by_uuid(mock_app):
|
||||
# Create and add test pipeline
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
await manager.load_pipeline(_context(), pipeline_entity)
|
||||
|
||||
# Test retrieval
|
||||
result = await manager.get_pipeline_by_uuid('test-uuid')
|
||||
result = await manager.get_pipeline_by_uuid(_context(), 'test-uuid')
|
||||
assert result is not None
|
||||
assert result.pipeline_entity.uuid == 'test-uuid'
|
||||
|
||||
# Test non-existent UUID
|
||||
result = await manager.get_pipeline_by_uuid('non-existent')
|
||||
result = await manager.get_pipeline_by_uuid(_context('non-existent'), 'non-existent')
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -105,15 +213,16 @@ async def test_remove_pipeline(mock_app):
|
||||
# Create and add test pipeline
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
await manager.load_pipeline(_context(), pipeline_entity)
|
||||
assert len(manager.pipelines) == 1
|
||||
|
||||
# Remove pipeline
|
||||
await manager.remove_pipeline('test-uuid')
|
||||
await manager.remove_pipeline(_context(), 'test-uuid')
|
||||
assert len(manager.pipelines) == 0
|
||||
|
||||
|
||||
@@ -143,128 +252,119 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
|
||||
# Create pipeline entity
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-pipeline-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.config = sample_query.pipeline_config
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
# Create runtime pipeline
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(
|
||||
mock_app,
|
||||
pipeline_entity,
|
||||
[stage_container],
|
||||
_context('test-pipeline-uuid'),
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Add query to cached_queries to prevent KeyError in finally block
|
||||
mock_app.query_pool.cached_queries[sample_query.query_id] = sample_query
|
||||
|
||||
# Execute pipeline
|
||||
await runtime_pipeline.run(sample_query)
|
||||
|
||||
# Verify stage was called
|
||||
mock_stage.process.assert_called_once()
|
||||
mock_app.query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_delivers_latest_chunk_as_final(mock_app, sample_query):
|
||||
"""The terminal chunk, not the first chunk, controls final stream delivery."""
|
||||
async def test_runtime_pipeline_rejects_stale_generation_before_side_effects(
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
entities = get_entities_module()
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-pipeline-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.config = sample_query.pipeline_config
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(
|
||||
mock_app,
|
||||
pipeline_entity,
|
||||
[],
|
||||
_context('test-pipeline-uuid'),
|
||||
)
|
||||
mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
|
||||
|
||||
with pytest.raises(WorkspaceGenerationMismatchError):
|
||||
await runtime_pipeline.run(sample_query)
|
||||
|
||||
mock_app.plugin_connector.emit_event.assert_not_awaited()
|
||||
sample_query.adapter.reply_message.assert_not_awaited()
|
||||
sample_query.adapter.reply_message_chunk.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_revalidates_after_awaited_stage(
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
stage = get_stage_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
entities = get_entities_module()
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-pipeline-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.config = sample_query.pipeline_config
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
|
||||
sample_query.resp_messages = [first_chunk, final_chunk]
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
result = entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=sample_query,
|
||||
user_notice='StartingDone',
|
||||
user_notice='must not be sent',
|
||||
console_notice='',
|
||||
debug_notice='',
|
||||
error_notice='',
|
||||
)
|
||||
|
||||
await runtime_pipeline._check_output(sample_query, result)
|
||||
async def stage_process(*_args):
|
||||
mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError(
|
||||
'generation changed during stage'
|
||||
)
|
||||
return result
|
||||
|
||||
sample_query.adapter.reply_message_chunk.assert_awaited_once()
|
||||
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
|
||||
assert call['bot_message'] is final_chunk
|
||||
assert call['is_final'] is True
|
||||
mock_stage = Mock(spec=stage.PipelineStage)
|
||||
mock_stage.process = Mock(side_effect=stage_process)
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(
|
||||
mock_app,
|
||||
pipeline_entity,
|
||||
[pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)],
|
||||
_context('test-pipeline-uuid'),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceGenerationMismatchError):
|
||||
await runtime_pipeline._execute_from_stage(0, sample_query)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_delivers_latest_chunk_as_final(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
|
||||
sample_query.resp_messages = [first_chunk, final_chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='StartingDone')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
sample_query.adapter.reply_message_chunk.assert_awaited_once()
|
||||
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
|
||||
assert call['bot_message'] is final_chunk
|
||||
assert call['is_final'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_keeps_consuming_after_stream_delivery_failure(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='Progress', is_final=False)
|
||||
sample_query.resp_messages = [chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Progress')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
|
||||
|
||||
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
assert result.result_type.name == 'CONTINUE'
|
||||
sample_query.adapter.reply_message.assert_not_awaited()
|
||||
sample_query.adapter.reply_message_chunk.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_falls_back_to_plain_message_for_failed_final_chunk(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='Final answer', is_final=True)
|
||||
sample_query.resp_messages = [chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Final answer')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
|
||||
|
||||
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
assert result.result_type.name == 'CONTINUE'
|
||||
sample_query.adapter.reply_message.assert_awaited_once()
|
||||
|
||||
|
||||
def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
|
||||
"""Runner resource selection should override extension preferences."""
|
||||
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
|
||||
"""AgentRunner 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.uuid = 'test-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
RUNNER_ID: {
|
||||
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
|
||||
'mcp-resource-agent-read-enabled': False,
|
||||
},
|
||||
@@ -276,22 +376,24 @@ def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [], _context())
|
||||
|
||||
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):
|
||||
"""Extension preferences apply when the current runner has no override."""
|
||||
"""Existing extension prefs remain compatible until a runner value exists."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.workspace_uuid = 'test-workspace'
|
||||
pipeline_entity.config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
|
||||
'runner_config': {'plugin:langbot-team/LocalAgent/default': {}},
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {RUNNER_ID: {}},
|
||||
}
|
||||
}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
@@ -299,99 +401,7 @@ def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [], _context())
|
||||
|
||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||
def test_runtime_pipeline_mcp_resource_read_flag_fails_closed(mock_app, invalid_value):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {
|
||||
'plugin:test/runner/default': {
|
||||
'mcp-resource-agent-read-enabled': invalid_value,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
pipeline_entity.extensions_preferences = {'mcp_resource_agent_read_enabled': True}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||
def test_runtime_pipeline_extension_enable_all_flags_fail_closed(mock_app, invalid_value):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'enable_all_plugins': invalid_value,
|
||||
'plugins': [{'author': 'allowed', 'name': 'plugin'}],
|
||||
'enable_all_mcp_servers': invalid_value,
|
||||
'mcp_servers': ['bound-mcp'],
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.enable_all_plugins is False
|
||||
assert runtime_pipeline.bound_plugins == ['allowed/plugin']
|
||||
assert runtime_pipeline.enable_all_mcp_servers is False
|
||||
assert runtime_pipeline.bound_mcp_servers == ['bound-mcp']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_preferences', [None, [], '', 0, False])
|
||||
def test_runtime_pipeline_malformed_extension_root_disables_all_extensions(
|
||||
mock_app,
|
||||
invalid_preferences,
|
||||
):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = invalid_preferences
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.enable_all_plugins is False
|
||||
assert runtime_pipeline.bound_plugins == []
|
||||
assert runtime_pipeline.enable_all_mcp_servers is False
|
||||
assert runtime_pipeline.bound_mcp_servers == []
|
||||
assert runtime_pipeline.mcp_resource_attachments == []
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
|
||||
def test_runtime_pipeline_malformed_extension_lists_are_empty_allowlists(mock_app):
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'enable_all_plugins': True,
|
||||
'plugins': 'allowed/plugin',
|
||||
'enable_all_mcp_servers': True,
|
||||
'mcp_servers': 'bound-mcp',
|
||||
'mcp_resources': 'file:///README.md',
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.enable_all_plugins is False
|
||||
assert runtime_pipeline.bound_plugins == []
|
||||
assert runtime_pipeline.enable_all_mcp_servers is False
|
||||
assert runtime_pipeline.bound_mcp_servers == []
|
||||
assert runtime_pipeline.mcp_resource_attachments == []
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
@@ -6,10 +6,52 @@ Tests query management, ID generation, and async context handling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.pipeline.pool import (
|
||||
ExecutionContextMismatchError,
|
||||
ExecutionContextRequiredError,
|
||||
QueryNotFoundError,
|
||||
QueryPool,
|
||||
QueryPoolCapacityError,
|
||||
get_query_execution_context,
|
||||
)
|
||||
|
||||
|
||||
TEST_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
def oss_pool():
|
||||
"""Build the explicit singleton resolver used by the OSS compatibility path."""
|
||||
return QueryPool(singleton_context_resolver=lambda: TEST_CONTEXT)
|
||||
|
||||
|
||||
async def add_scoped_mock_query(pool, context, *, bot_uuid='bot-a'):
|
||||
"""Create a Query through the real pool while keeping SDK details mocked."""
|
||||
query = Mock()
|
||||
query.bot_uuid = bot_uuid
|
||||
query.pipeline_uuid = None
|
||||
query.query_id = pool.query_id_counter
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query):
|
||||
return await pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=Mock(),
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
execution_context=context,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -39,7 +81,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_adds_query_with_id(self):
|
||||
"""add_query creates, stores, and caches a Query with the correct ID."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
# Mock Query creation
|
||||
mock_query = Mock()
|
||||
@@ -62,12 +104,12 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
# Query is added to list and cache
|
||||
assert pool.queries[0] is mock_query
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
assert pool.cached_queries[('workspace-test', mock_query.query_uuid)] is mock_query
|
||||
assert mock_query.query_id == 0
|
||||
|
||||
async def test_add_query_increments_counter(self):
|
||||
"""Each add_query increments the counter."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query1 = Mock()
|
||||
mock_query1.query_id = 0
|
||||
@@ -103,7 +145,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_appends_to_list(self):
|
||||
"""Query is appended to queries list."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -126,7 +168,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_caches_query(self):
|
||||
"""Query is cached by query_id."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -144,12 +186,13 @@ class TestQueryPoolAddQuery:
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert 0 in pool.cached_queries
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
cache_key = ('workspace-test', mock_query.query_uuid)
|
||||
assert cache_key in pool.cached_queries
|
||||
assert pool.cached_queries[cache_key] is mock_query
|
||||
|
||||
async def test_add_query_with_pipeline_uuid(self):
|
||||
"""Query can have pipeline_uuid set."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -175,7 +218,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_sets_routed_by_rule_variable(self):
|
||||
"""Query has _routed_by_rule variable."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -201,7 +244,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_merges_control_variables(self):
|
||||
"""Caller-provided control variables are preserved with routing metadata."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
mock_query = Mock(query_id=0)
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
@@ -224,7 +267,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_notifier_condition(self):
|
||||
"""add_query notifies waiting consumers."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -260,7 +303,7 @@ class TestQueryPoolContext:
|
||||
|
||||
async def test_aenter_acquires_lock(self):
|
||||
"""__aenter__ acquires the pool lock."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
async with pool as p:
|
||||
# Lock is acquired
|
||||
@@ -283,7 +326,7 @@ class TestQueryPoolEdgeCases:
|
||||
|
||||
async def test_multiple_queries_cached_correctly(self):
|
||||
"""Multiple queries are cached separately."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_queries = []
|
||||
for i in range(5):
|
||||
@@ -310,4 +353,159 @@ class TestQueryPoolEdgeCases:
|
||||
|
||||
# Each query is cached by its ID
|
||||
for i in range(5):
|
||||
assert pool.cached_queries[i] is mock_queries[i]
|
||||
query = mock_queries[i]
|
||||
assert pool.cached_queries[('workspace-test', query.query_uuid)] is query
|
||||
|
||||
|
||||
class TestQueryPoolWorkspaceIsolation:
|
||||
"""Regression coverage for trusted scope and scoped cache indexes."""
|
||||
|
||||
async def test_add_query_requires_execution_context_by_default(self):
|
||||
with pytest.raises(ExecutionContextRequiredError):
|
||||
await QueryPool().add_query(
|
||||
bot_uuid='bot-a',
|
||||
launcher_type=Mock(),
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
async def test_serialized_scope_fields_are_not_trusted_context(self):
|
||||
forged_query = SimpleNamespace(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
bot_uuid='bot-a',
|
||||
pipeline_uuid=None,
|
||||
query_uuid='forged-query',
|
||||
)
|
||||
|
||||
with pytest.raises(ExecutionContextRequiredError):
|
||||
get_query_execution_context(forged_query)
|
||||
|
||||
async def test_query_lookup_is_workspace_scoped(self):
|
||||
pool = QueryPool()
|
||||
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
uuid.UUID(query.query_uuid)
|
||||
assert await pool.get_query('workspace-test', query.query_uuid) is query
|
||||
assert await pool.get_query('workspace-other', query.query_uuid) is None
|
||||
assert await pool.get_query_by_legacy_id('workspace-test', 0) is query
|
||||
assert await pool.get_query_by_legacy_id('workspace-other', 0) is None
|
||||
with pytest.raises(QueryNotFoundError):
|
||||
await pool.require_query('workspace-other', query.query_uuid)
|
||||
|
||||
async def test_cache_separates_same_opaque_id_between_workspaces(self, monkeypatch):
|
||||
fixed_uuid = uuid.UUID('11111111-1111-4111-8111-111111111111')
|
||||
monkeypatch.setattr('langbot.pkg.pipeline.pool.uuid.uuid4', lambda: fixed_uuid)
|
||||
pool = QueryPool()
|
||||
context_a = TEST_CONTEXT
|
||||
context_b = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-other',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
query_a = await add_scoped_mock_query(pool, context_a)
|
||||
query_b = await add_scoped_mock_query(pool, context_b)
|
||||
|
||||
assert query_a.query_uuid == query_b.query_uuid
|
||||
assert await pool.get_query('workspace-test', query_a.query_uuid) is query_a
|
||||
assert await pool.get_query('workspace-other', query_b.query_uuid) is query_b
|
||||
|
||||
async def test_remove_query_cleans_both_scoped_indexes(self):
|
||||
pool = QueryPool()
|
||||
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
assert await pool.remove_query(query) is True
|
||||
assert await pool.get_query('workspace-test', query.query_uuid) is None
|
||||
assert await pool.get_query_by_legacy_id('workspace-test', query.query_id) is None
|
||||
assert await pool.remove_query(query) is False
|
||||
|
||||
async def test_context_cannot_substitute_bot_identity(self):
|
||||
context = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
bot_uuid='bot-b',
|
||||
)
|
||||
|
||||
with pytest.raises(ExecutionContextMismatchError):
|
||||
await add_scoped_mock_query(QueryPool(), context, bot_uuid='bot-a')
|
||||
|
||||
async def test_query_counter_is_scoped_by_workspace_and_generation(self):
|
||||
pool = QueryPool()
|
||||
workspace_a = TEST_CONTEXT
|
||||
workspace_b = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-other',
|
||||
placement_generation=1,
|
||||
)
|
||||
next_generation = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=2,
|
||||
)
|
||||
|
||||
await add_scoped_mock_query(pool, workspace_a)
|
||||
await add_scoped_mock_query(pool, workspace_a)
|
||||
await add_scoped_mock_query(pool, workspace_b)
|
||||
|
||||
assert pool.get_query_count(workspace_a) == 2
|
||||
assert pool.get_query_count(workspace_b) == 1
|
||||
assert pool.get_query_count(next_generation) == 0
|
||||
assert pool.query_id_counter == 3
|
||||
|
||||
async def test_workspace_capacity_discards_oldest_queued_query(self):
|
||||
pool = QueryPool(max_queries=3, max_queries_per_workspace=2)
|
||||
first = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
second = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
third = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
assert await pool.get_query(TEST_CONTEXT.workspace_uuid, first.query_uuid) is None
|
||||
assert await pool.get_query(TEST_CONTEXT.workspace_uuid, second.query_uuid) is second
|
||||
assert await pool.get_query(TEST_CONTEXT.workspace_uuid, third.query_uuid) is third
|
||||
assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 2}
|
||||
assert pool.get_dropped_query_count(TEST_CONTEXT) == 1
|
||||
|
||||
async def test_capacity_rejects_when_every_query_is_already_running(self):
|
||||
pool = QueryPool(max_queries=1, max_queries_per_workspace=1)
|
||||
running = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
async with pool:
|
||||
pool.mark_query_running_locked(running)
|
||||
|
||||
with pytest.raises(QueryPoolCapacityError):
|
||||
await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 1}
|
||||
|
||||
async def test_mark_query_running_keeps_active_indexes_but_removes_queue_entry(self):
|
||||
pool = QueryPool(max_queries=1, max_queries_per_workspace=1)
|
||||
running = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
async with pool:
|
||||
pool.mark_query_running_locked(running)
|
||||
|
||||
assert running not in pool.queries
|
||||
assert await pool.get_query(TEST_CONTEXT.workspace_uuid, running.query_uuid) is running
|
||||
assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 1}
|
||||
|
||||
async def test_historical_workspace_counters_are_bounded(self):
|
||||
pool = QueryPool(max_queries=2, max_queries_per_workspace=1)
|
||||
contexts = [
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid=f'workspace-{index}',
|
||||
placement_generation=1,
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
for context in contexts:
|
||||
query = await add_scoped_mock_query(pool, context)
|
||||
await pool.remove_query(query)
|
||||
|
||||
assert len(pool.query_count_by_scope) == 2
|
||||
assert (contexts[0].instance_uuid, contexts[0].workspace_uuid, 1) not in pool.query_count_by_scope
|
||||
|
||||
@@ -15,6 +15,9 @@ import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
@@ -24,6 +27,28 @@ from tests.factories import (
|
||||
)
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
|
||||
def attach_agent_runner_descriptor(app):
|
||||
descriptor = AgentRunnerDescriptor(
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='LocalAgent',
|
||||
runner_name='default',
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'model-fallback-selector'},
|
||||
{'name': 'prompt', 'type': 'prompt-editor', 'default': []},
|
||||
],
|
||||
capabilities={'tool_calling': True, 'multimodal_input': True},
|
||||
)
|
||||
app.agent_runner_registry = Mock()
|
||||
app.agent_runner_registry.get = AsyncMock(return_value=descriptor)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
|
||||
|
||||
def get_preproc_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
return import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
@@ -34,48 +59,18 @@ def get_entities_module():
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
def make_session(
|
||||
launcher_type: provider_session.LauncherTypes = provider_session.LauncherTypes.PERSON,
|
||||
launcher_id: int = 12345,
|
||||
) -> provider_session.Session:
|
||||
"""Build a scope-aware Session that matches the shared Query factory."""
|
||||
|
||||
|
||||
def attach_agent_runner_descriptor(app, *, multimodal_input=True, tool_calling=True):
|
||||
"""Attach a schema-backed AgentRunner descriptor to a FakeApp."""
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
|
||||
descriptor = AgentRunnerDescriptor(
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='LocalAgent',
|
||||
runner_name='default',
|
||||
config_schema=[
|
||||
{'name': 'model', 'type': 'model-fallback-selector'},
|
||||
{'name': 'prompt', 'type': 'prompt-editor', 'default': []},
|
||||
],
|
||||
capabilities={
|
||||
'tool_calling': tool_calling,
|
||||
'multimodal_input': multimodal_input,
|
||||
},
|
||||
return provider_session.Session(
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=12345,
|
||||
bot_uuid='test-bot-uuid',
|
||||
)
|
||||
app.agent_runner_registry = Mock()
|
||||
app.agent_runner_registry.get = AsyncMock(return_value=descriptor)
|
||||
return descriptor
|
||||
|
||||
|
||||
def agent_runner_pipeline_config(model_config, *, prompt='default'):
|
||||
return {
|
||||
'ai': {
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {
|
||||
RUNNER_ID: {
|
||||
'model': model_config,
|
||||
'prompt': prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
|
||||
class TestPreProcessorNormalText:
|
||||
@@ -89,9 +84,7 @@ class TestPreProcessorNormalText:
|
||||
|
||||
app = FakeApp()
|
||||
# Mock session manager to return a session
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
# Mock conversation
|
||||
@@ -112,7 +105,7 @@ class TestPreProcessorNormalText:
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
|
||||
# Mock tool manager
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
# Mock plugin connector
|
||||
mock_event_ctx = Mock()
|
||||
@@ -135,9 +128,7 @@ class TestPreProcessorNormalText:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -150,7 +141,7 @@ class TestPreProcessorNormalText:
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -175,9 +166,7 @@ class TestPreProcessorEmptyMessage:
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -188,7 +177,7 @@ class TestPreProcessorEmptyMessage:
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -214,9 +203,7 @@ class TestPreProcessorImageSegment:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -230,7 +217,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)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -262,9 +249,7 @@ class TestPreProcessorImageSegment:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -278,7 +263,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)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -301,9 +286,7 @@ class TestPreProcessorModelSelection:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -316,7 +299,6 @@ class TestPreProcessorModelSelection:
|
||||
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_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
attach_agent_runner_descriptor(app)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
@@ -327,9 +309,19 @@ class TestPreProcessorModelSelection:
|
||||
query = text_query('hello')
|
||||
|
||||
# Set pipeline config with primary model
|
||||
query.pipeline_config = agent_runner_pipeline_config(
|
||||
{'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
)
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {
|
||||
RUNNER_ID: {
|
||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
'prompt': [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -341,9 +333,7 @@ class TestPreProcessorModelSelection:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -360,7 +350,7 @@ class TestPreProcessorModelSelection:
|
||||
mock_fallback = Mock()
|
||||
mock_fallback.model_entity = Mock(uuid='fallback-uuid', abilities=['func_call'])
|
||||
|
||||
async def mock_get_model(uuid):
|
||||
async def mock_get_model(_context, uuid):
|
||||
if uuid == 'primary-uuid':
|
||||
return mock_primary
|
||||
elif uuid == 'fallback-uuid':
|
||||
@@ -368,7 +358,6 @@ class TestPreProcessorModelSelection:
|
||||
raise ValueError(f'Model {uuid} not found')
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
attach_agent_runner_descriptor(app)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
@@ -378,9 +367,19 @@ class TestPreProcessorModelSelection:
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query('hello')
|
||||
|
||||
query.pipeline_config = agent_runner_pipeline_config(
|
||||
{'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']},
|
||||
)
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {
|
||||
RUNNER_ID: {
|
||||
'model': {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']},
|
||||
'prompt': [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
@@ -397,9 +396,7 @@ class TestPreProcessorVariables:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -410,7 +407,7 @@ class TestPreProcessorVariables:
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -434,9 +431,10 @@ class TestPreProcessorVariables:
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='group')
|
||||
mock_session.launcher_id = 99999
|
||||
mock_session = make_session(
|
||||
provider_session.LauncherTypes.GROUP,
|
||||
99999,
|
||||
)
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -447,7 +445,7 @@ class TestPreProcessorVariables:
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
@@ -462,69 +460,17 @@ class TestPreProcessorVariables:
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
@pytest.mark.parametrize(
|
||||
('configured_skills', 'expected_skills'),
|
||||
[
|
||||
(['bound-skill'], ['bound-skill']),
|
||||
(None, []),
|
||||
('bound-skill', []),
|
||||
],
|
||||
)
|
||||
async def test_malformed_enable_all_skills_flag_uses_bound_skills(
|
||||
self,
|
||||
invalid_value,
|
||||
configured_skills,
|
||||
expected_skills,
|
||||
):
|
||||
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)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
app.pipeline_service.get_pipeline = AsyncMock(
|
||||
return_value={
|
||||
'extensions_preferences': {
|
||||
'enable_all_skills': invalid_value,
|
||||
'skills': configured_skills,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
result = await preproc.PreProcessor(app).process(text_query('hello'), 'PreProcessor')
|
||||
|
||||
assert result.new_query.variables['_pipeline_bound_skills'] == expected_skills
|
||||
|
||||
|
||||
class TestPreProcessorToolSelection:
|
||||
"""Tests for generic AgentRunner tool selection."""
|
||||
"""Tests for Local Agent tool selection."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_runner_filters_selected_tools(self):
|
||||
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
|
||||
mock_session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
@@ -537,98 +483,37 @@ class TestPreProcessorToolSelection:
|
||||
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)
|
||||
attach_agent_runner_descriptor(app)
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'exec',
|
||||
'source': 'builtin',
|
||||
'description': 'Execute',
|
||||
'parameters': {},
|
||||
},
|
||||
{
|
||||
'name': 'plugin_tool',
|
||||
'source': 'plugin',
|
||||
'source_id': 'test/plugin',
|
||||
'description': 'Plugin tool',
|
||||
'parameters': {},
|
||||
},
|
||||
{
|
||||
'name': 'mcp_tool',
|
||||
'source': 'mcp',
|
||||
'source_id': 'mcp-server',
|
||||
'description': 'MCP tool',
|
||||
'parameters': {},
|
||||
},
|
||||
{'name': 'exec', 'source': 'builtin'},
|
||||
{'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'},
|
||||
{'name': 'mcp_tool', 'source': 'mcp', 'source_id': 'test-mcp'},
|
||||
]
|
||||
)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
attach_agent_runner_descriptor(app)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = agent_runner_pipeline_config(
|
||||
{'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
)
|
||||
query.pipeline_config['ai']['runner_config'][RUNNER_ID].update(
|
||||
{
|
||||
'enable-all-tools': False,
|
||||
'tools': ['plugin_tool'],
|
||||
}
|
||||
)
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'id': RUNNER_ID},
|
||||
'runner_config': {
|
||||
RUNNER_ID: {
|
||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
'prompt': [],
|
||||
'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']
|
||||
assert result.new_query.variables['_host_tool_source_refs'] == {
|
||||
'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'},
|
||||
}
|
||||
|
||||
|
||||
class TestPreProcessorMCPResourceContext:
|
||||
"""Tests for deferring MCP context until the run-scoped execution input."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinned_context_does_not_mutate_preprocessed_input(self):
|
||||
preproc = get_preproc_module()
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
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 = 'conversation-1'
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=[])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
mcp_loader = Mock()
|
||||
mcp_loader.build_resource_context_for_query = AsyncMock(return_value='Pinned documentation')
|
||||
app.tool_mgr.mcp_tool_loader = mcp_loader
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
attach_agent_runner_descriptor(app, tool_calling=False)
|
||||
|
||||
query = text_query('hello')
|
||||
query.launcher_id = '12345'
|
||||
query.pipeline_config = agent_runner_pipeline_config(
|
||||
{'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
)
|
||||
|
||||
result = await preproc.PreProcessor(app).process(query, 'PreProcessor')
|
||||
event = QueryEntryAdapter.query_to_event(result.new_query)
|
||||
|
||||
assert event.input.text == 'hello'
|
||||
assert 'Pinned documentation' not in str(event.input.contents)
|
||||
mcp_loader.build_resource_context_for_query.assert_not_awaited()
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
@@ -73,7 +74,12 @@ async def test_preprocessor_keeps_image_placeholder_for_text_only_local_agent(mo
|
||||
mock_app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model)
|
||||
_attach_agent_runner_descriptor(mock_app)
|
||||
mock_app.sess_mgr.get_session = AsyncMock(
|
||||
return_value=SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
|
||||
return_value=provider_session.Session(
|
||||
launcher_type=sample_query.launcher_type,
|
||||
launcher_id=sample_query.launcher_id,
|
||||
sender_id=sample_query.sender_id,
|
||||
bot_uuid=sample_query.bot_uuid,
|
||||
)
|
||||
)
|
||||
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=_conversation())
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
|
||||
|
||||
@@ -9,6 +9,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
class DummyEventLogger(abstract_platform_logger.AbstractEventLogger):
|
||||
@@ -64,12 +65,18 @@ async def test_add_query_returns_created_query_and_preserves_side_effects(
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=True,
|
||||
execution_context=ExecutionContext(
|
||||
instance_uuid='test-instance-uuid',
|
||||
workspace_uuid='test-workspace-uuid',
|
||||
placement_generation=1,
|
||||
),
|
||||
)
|
||||
|
||||
assert query is query_pool.queries[0]
|
||||
assert query_pool.cached_queries[0] is query
|
||||
assert query_pool.cached_queries[('test-workspace-uuid', query.query_uuid)] is query
|
||||
assert query_pool.query_id_counter == 1
|
||||
assert query.query_id == 0
|
||||
assert query.bot_uuid == 'test-bot-uuid'
|
||||
assert query.pipeline_uuid == 'test-pipeline-uuid'
|
||||
assert query.workspace_uuid == 'test-workspace-uuid'
|
||||
assert query.variables == {'_routed_by_rule': True}
|
||||
|
||||
@@ -152,8 +152,18 @@ class TestFixedWindowAlgo:
|
||||
# First request creates container
|
||||
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
|
||||
|
||||
# Key format: 'LauncherTypes.PERSON_12345' (enum string representation)
|
||||
expected_key = 'LauncherTypes.PERSON_12345'
|
||||
context = sample_query_with_rate_limit._execution_context
|
||||
expected_key = ':'.join(
|
||||
(
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
str(context.placement_generation),
|
||||
str(sample_query_with_rate_limit.bot_uuid),
|
||||
str(sample_query_with_rate_limit.pipeline_uuid),
|
||||
str(provider_session.LauncherTypes.PERSON),
|
||||
'12345',
|
||||
)
|
||||
)
|
||||
assert expected_key in algo.containers
|
||||
container = algo.containers[expected_key]
|
||||
|
||||
@@ -191,8 +201,18 @@ class TestFixedWindowAlgo:
|
||||
for i in range(5):
|
||||
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
|
||||
|
||||
# Key format: 'LauncherTypes.PERSON_test'
|
||||
expected_key = 'LauncherTypes.PERSON_test'
|
||||
context = sample_query._execution_context
|
||||
expected_key = ':'.join(
|
||||
(
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
str(context.placement_generation),
|
||||
str(sample_query.bot_uuid),
|
||||
str(sample_query.pipeline_uuid),
|
||||
str(provider_session.LauncherTypes.PERSON),
|
||||
'test',
|
||||
)
|
||||
)
|
||||
container = algo.containers[expected_key]
|
||||
assert window_start in container.records
|
||||
assert container.records[window_start] == 5
|
||||
|
||||
Reference in New Issue
Block a user