mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
refactor(agent-runner): tighten protocol v1 runtime boundaries
This commit is contained in:
committed by
huanghuoguoguo
parent
f9e07df539
commit
2fd2c6aadc
@@ -1,67 +1,11 @@
|
||||
"""Tests for agent run context builder params and state."""
|
||||
"""Tests for Pipeline adapter params and prompt packaging."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.state_store import reset_state_store
|
||||
|
||||
# Import shared test fixtures from conftest.py
|
||||
from .conftest import make_resources
|
||||
|
||||
|
||||
class FakeApplication:
|
||||
"""Fake Application for testing."""
|
||||
def __init__(self):
|
||||
class FakeLogger:
|
||||
def info(self, msg):
|
||||
pass
|
||||
def debug(self, msg):
|
||||
pass
|
||||
def warning(self, msg):
|
||||
pass
|
||||
def error(self, msg):
|
||||
pass
|
||||
|
||||
class FakeVersionManager:
|
||||
def get_current_version(self):
|
||||
return '1.0.0'
|
||||
|
||||
self.logger = FakeLogger()
|
||||
self.ver_mgr = FakeVersionManager()
|
||||
|
||||
|
||||
def make_descriptor() -> AgentRunnerDescriptor:
|
||||
"""Create a test descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
id='plugin:langbot/local-agent/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
plugin_author='langbot',
|
||||
plugin_name='local-agent',
|
||||
runner_name='default',
|
||||
protocol_version='1',
|
||||
capabilities={'streaming': True},
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Fake session for testing."""
|
||||
def __init__(self):
|
||||
self.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
|
||||
self.launcher_id = 'group_123'
|
||||
self.using_conversation = None
|
||||
|
||||
|
||||
class FakeConversation:
|
||||
"""Fake conversation for testing."""
|
||||
def __init__(self, uuid: str = 'conv_abc'):
|
||||
self.uuid = uuid
|
||||
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
"""Fake message for testing."""
|
||||
"""Fake prompt/history message."""
|
||||
def __init__(self, content='Hello'):
|
||||
self.content = content
|
||||
self.role = 'user'
|
||||
@@ -76,32 +20,14 @@ class FakePrompt:
|
||||
self.messages = messages or []
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
"""Fake adapter with streaming capability."""
|
||||
async def is_stream_output_supported(self):
|
||||
return True
|
||||
|
||||
|
||||
class TestBuildParams:
|
||||
"""Tests for _build_params filtering."""
|
||||
"""Tests for PipelineAdapter.build_params filtering."""
|
||||
|
||||
def test_params_empty_when_no_variables(self):
|
||||
"""Empty variables should produce empty params."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': None,
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
assert params == {}
|
||||
query = type('Query', (), {'variables': None})()
|
||||
assert PipelineAdapter.build_params(query) == {}
|
||||
|
||||
def test_params_filters_underscore_prefix(self):
|
||||
"""Params should exclude variables starting with underscore."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'_internal_var': 'should_be_excluded',
|
||||
@@ -111,18 +37,13 @@ class TestBuildParams:
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
params = PipelineAdapter.build_params(query)
|
||||
assert '_internal_var' not in params
|
||||
assert '_pipeline_bound_plugins' not in params
|
||||
assert '_monitoring_bot_name' not in params
|
||||
assert 'public_var' in params
|
||||
assert params['public_var'] == 'should_be_included'
|
||||
|
||||
def test_params_filters_sensitive_naming(self):
|
||||
"""Params should exclude variables with sensitive naming patterns."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'api_key': 'secret123',
|
||||
@@ -140,8 +61,7 @@ class TestBuildParams:
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# All sensitive patterns should be excluded
|
||||
params = PipelineAdapter.build_params(query)
|
||||
assert 'api_key' not in params
|
||||
assert 'API_KEY' not in params
|
||||
assert 'token' not in params
|
||||
@@ -152,15 +72,10 @@ class TestBuildParams:
|
||||
assert 'user_secret_key' not in params
|
||||
assert 'my_token_value' not in params
|
||||
assert 'user_password_hash' not in params
|
||||
# Public vars should be included
|
||||
assert 'public_name' in params
|
||||
assert 'safe_value' in params
|
||||
|
||||
def test_params_keeps_common_public_vars(self):
|
||||
"""Params should keep common public business vars."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'launcher_type': 'telegram',
|
||||
@@ -174,8 +89,7 @@ class TestBuildParams:
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# All these should be included
|
||||
params = PipelineAdapter.build_params(query)
|
||||
assert params['launcher_type'] == 'telegram'
|
||||
assert params['launcher_id'] == 'group_123'
|
||||
assert params['sender_id'] == 'user_001'
|
||||
@@ -186,10 +100,6 @@ class TestBuildParams:
|
||||
assert params['user_message_text'] == 'Hello world'
|
||||
|
||||
def test_params_filters_non_json_serializable(self):
|
||||
"""Params should keep only JSON-serializable values."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
@@ -202,11 +112,11 @@ class TestBuildParams:
|
||||
'null_value': None,
|
||||
'list_value': ['a', 'b', 'c'],
|
||||
'dict_value': {'nested': 'value'},
|
||||
'custom_object': CustomObject(), # Not serializable
|
||||
'custom_object': CustomObject(),
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
params = PipelineAdapter.build_params(query)
|
||||
assert 'string_value' in params
|
||||
assert 'int_value' in params
|
||||
assert 'float_value' in params
|
||||
@@ -217,288 +127,53 @@ class TestBuildParams:
|
||||
assert 'custom_object' not in params
|
||||
|
||||
def test_params_filters_nested_non_serializable(self):
|
||||
"""Params should filter nested non-serializable values."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'nested_list_with_bad': ['a', CustomObject(), 'c'], # List with non-serializable
|
||||
'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()}, # Dict with non-serializable
|
||||
'nested_list_with_bad': ['a', CustomObject(), 'c'],
|
||||
'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()},
|
||||
'good_nested_list': ['a', ['b', 'c']],
|
||||
'good_nested_dict': {'outer': {'inner': 'value'}},
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# Nested with bad should be excluded
|
||||
params = PipelineAdapter.build_params(query)
|
||||
assert 'nested_list_with_bad' not in params
|
||||
assert 'nested_dict_with_bad' not in params
|
||||
# Good nested should be included
|
||||
assert 'good_nested_list' in params
|
||||
assert 'good_nested_dict' in params
|
||||
|
||||
def test_is_json_serializable_primitives(self):
|
||||
"""_is_json_serializable should return True for primitives."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
assert builder._is_json_serializable(None) is True
|
||||
assert builder._is_json_serializable('string') is True
|
||||
assert builder._is_json_serializable(42) is True
|
||||
assert builder._is_json_serializable(3.14) is True
|
||||
assert builder._is_json_serializable(True) is True
|
||||
assert builder._is_json_serializable(False) is True
|
||||
|
||||
def test_is_json_serializable_collections(self):
|
||||
"""_is_json_serializable should check nested collections."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
assert builder._is_json_serializable([]) is True
|
||||
assert builder._is_json_serializable(['a', 'b']) is True
|
||||
assert builder._is_json_serializable({}) is True
|
||||
assert builder._is_json_serializable({'key': 'value'}) is True
|
||||
assert builder._is_json_serializable([1, 2, [3, 4]]) is True
|
||||
assert builder._is_json_serializable({'a': {'b': 'c'}}) is True
|
||||
|
||||
def test_is_json_serializable_custom_objects(self):
|
||||
"""_is_json_serializable should return False for custom objects."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
def test_is_json_serializable_primitives_and_collections(self):
|
||||
assert PipelineAdapter.is_json_serializable(None) is True
|
||||
assert PipelineAdapter.is_json_serializable('string') is True
|
||||
assert PipelineAdapter.is_json_serializable(42) is True
|
||||
assert PipelineAdapter.is_json_serializable(['a', 'b']) is True
|
||||
assert PipelineAdapter.is_json_serializable({'key': 'value'}) is True
|
||||
assert PipelineAdapter.is_json_serializable((1, 2, 3)) is True
|
||||
|
||||
def test_is_json_serializable_rejects_sets_and_objects(self):
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
assert builder._is_json_serializable(CustomObject()) is False
|
||||
assert builder._is_json_serializable([CustomObject()]) is False
|
||||
assert builder._is_json_serializable({'key': CustomObject()}) is False
|
||||
assert PipelineAdapter.is_json_serializable(CustomObject()) is False
|
||||
assert PipelineAdapter.is_json_serializable({1, 2, 3}) is False
|
||||
assert PipelineAdapter.is_json_serializable([1, {2, 3}]) is False
|
||||
assert PipelineAdapter.is_json_serializable({'key': {1, 2}}) is False
|
||||
|
||||
def test_is_json_serializable_set_not_allowed(self):
|
||||
"""_is_json_serializable should return False for set (not JSON-serializable).
|
||||
|
||||
json.dumps({"x": {1}}) fails because set is not JSON-serializable.
|
||||
Only list and tuple are allowed.
|
||||
"""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
class TestBuildPrompt:
|
||||
"""Tests for PipelineAdapter.build_prompt."""
|
||||
|
||||
# set is NOT JSON-serializable
|
||||
assert builder._is_json_serializable({1, 2, 3}) is False
|
||||
assert builder._is_json_serializable({'a', 'b'}) is False
|
||||
# list and tuple ARE allowed
|
||||
assert builder._is_json_serializable([1, 2, 3]) is True
|
||||
assert builder._is_json_serializable((1, 2, 3)) is True
|
||||
# Nested set should also be rejected
|
||||
assert builder._is_json_serializable([1, {2, 3}]) is False
|
||||
assert builder._is_json_serializable({'key': {1, 2}}) is False
|
||||
|
||||
def test_params_filters_set_values(self):
|
||||
"""Params should filter out variables with set values.
|
||||
|
||||
set is not JSON-serializable and would cause json.dumps to fail.
|
||||
"""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
def test_prompt_empty_when_missing(self):
|
||||
query = type('Query', (), {})()
|
||||
assert PipelineAdapter.build_prompt(query) == []
|
||||
|
||||
def test_prompt_serializes_messages(self):
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'list_value': ['a', 'b', 'c'],
|
||||
'tuple_value': ('a', 'b', 'c'),
|
||||
'set_value': {'a', 'b', 'c'}, # Should be filtered
|
||||
'nested_with_set': ['a', {'b', 'c'}], # Should be filtered
|
||||
'dict_with_set': {'items': {1, 2}}, # Should be filtered
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# list and tuple should be included
|
||||
assert 'list_value' in params
|
||||
assert params['list_value'] == ['a', 'b', 'c']
|
||||
assert 'tuple_value' in params
|
||||
# set should be filtered
|
||||
assert 'set_value' not in params
|
||||
assert 'nested_with_set' not in params
|
||||
assert 'dict_with_set' not in params
|
||||
|
||||
|
||||
class TestBuildState:
|
||||
"""Tests for state snapshot building."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_has_state_field(self):
|
||||
"""AgentRunContext should have state field."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
session = FakeSession()
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
assert 'state' in context
|
||||
assert 'conversation' in context['state']
|
||||
assert 'actor' in context['state']
|
||||
assert 'subject' in context['state']
|
||||
assert 'runner' in context['state']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_seeds_conversation_id_from_existing(self):
|
||||
"""State should seed external.conversation_id from existing conversation uuid."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
conversation = FakeConversation(uuid='conv_existing')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
assert context['state']['conversation']['external.conversation_id'] == 'conv_existing'
|
||||
|
||||
|
||||
class TestBuildParamsInContext:
|
||||
"""Tests for params in full context."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_has_params_field(self):
|
||||
"""AgentRunContext should have params field."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
session = FakeSession()
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {
|
||||
'public_param': 'value',
|
||||
'_private': 'excluded',
|
||||
},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
# Protocol v1: params is in adapter.extra
|
||||
assert 'adapter' in context
|
||||
assert 'extra' in context['adapter']
|
||||
assert 'params' in context['adapter']['extra']
|
||||
assert context['adapter']['extra']['params']['public_param'] == 'value'
|
||||
assert '_private' not in context['adapter']['extra']['params']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_params_and_state_both_present(self):
|
||||
"""Context should have both params and state."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
conversation = FakeConversation(uuid='conv_abc')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {
|
||||
'workflow_input': 'user_question',
|
||||
'sender_name': 'John',
|
||||
},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
# Protocol v1: params is in adapter.extra
|
||||
assert 'adapter' in context
|
||||
assert 'extra' in context['adapter']
|
||||
assert 'params' in context['adapter']['extra']
|
||||
assert context['adapter']['extra']['params']['workflow_input'] == 'user_question'
|
||||
assert context['adapter']['extra']['params']['sender_name'] == 'John'
|
||||
|
||||
# state should have seeded conversation_id
|
||||
assert 'state' in context
|
||||
assert context['state']['conversation']['external.conversation_id'] == 'conv_abc'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_includes_effective_prompt_and_runtime_capabilities(self):
|
||||
"""Context should expose host-preprocessed prompt and adapter capabilities."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
session = FakeSession()
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'prompt': FakePrompt([FakeMessage('Effective prompt')]),
|
||||
'adapter': FakeAdapter(),
|
||||
'pipeline_config': {'output': {'misc': {'remove-think': True}}},
|
||||
'variables': {},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
# Protocol v1: prompt is in adapter.extra
|
||||
assert 'adapter' in context
|
||||
assert 'extra' in context['adapter']
|
||||
assert 'prompt' in context['adapter']['extra']
|
||||
assert context['adapter']['extra']['prompt'][0]['content'] == 'Effective prompt'
|
||||
assert context['runtime']['metadata']['streaming_supported'] is True
|
||||
assert context['runtime']['metadata']['remove_think'] is True
|
||||
prompt = PipelineAdapter.build_prompt(query)
|
||||
assert prompt == [{'role': 'user', 'content': 'Effective prompt'}]
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestContextAccessStateDetermination:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor):
|
||||
"""ContextAccess.state=False when binding is None (legacy mode)."""
|
||||
"""ContextAccess.state=False when no binding is provided."""
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
|
||||
# Real call without binding
|
||||
|
||||
@@ -54,8 +54,9 @@ class TestContextValidation:
|
||||
event_type="message.received",
|
||||
event_time=1700000000,
|
||||
source="platform",
|
||||
source_event_type="platform.message",
|
||||
bot_id="bot_1",
|
||||
workspace_id=None,
|
||||
workspace_id="workspace_1",
|
||||
conversation_id="conv_1",
|
||||
thread_id=None,
|
||||
actor=ActorContext(
|
||||
@@ -66,6 +67,7 @@ class TestContextValidation:
|
||||
subject=None,
|
||||
input=EventInput(text="Hello world"),
|
||||
delivery=DeliveryContext(surface="test"),
|
||||
data={"platform_event_id": "source_evt_1"},
|
||||
)
|
||||
|
||||
def _make_binding(self) -> AgentBinding:
|
||||
@@ -155,6 +157,13 @@ class TestContextValidation:
|
||||
assert validated.event.event_id == "evt_1"
|
||||
assert validated.event.event_type == "message.received"
|
||||
assert validated.event.source == "platform"
|
||||
assert validated.event.source_event_type == "platform.message"
|
||||
assert validated.event.data == {"platform_event_id": "source_evt_1"}
|
||||
|
||||
# Verify conversation context uses SDK field names
|
||||
assert validated.conversation is not None
|
||||
assert validated.conversation.bot_id == "bot_1"
|
||||
assert validated.conversation.workspace_id == "workspace_1"
|
||||
|
||||
# Verify delivery context
|
||||
assert validated.delivery.surface == "test"
|
||||
|
||||
@@ -88,6 +88,36 @@ class TestPipelineQueryToEventEnvelope:
|
||||
assert event.delivery.surface == "platform"
|
||||
assert isinstance(event.delivery.supports_streaming, bool)
|
||||
|
||||
def test_query_to_event_preserves_source_event_data(self, mock_query):
|
||||
"""Test source event metadata survives the adapter boundary."""
|
||||
source_event = Mock()
|
||||
source_event.type = "platform.message.created"
|
||||
source_event.time = 1700000000
|
||||
source_event.sender = None
|
||||
source_event.model_dump = Mock(return_value={
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
"source_platform_object": {"large": "payload"},
|
||||
})
|
||||
mock_query.message_event = source_event
|
||||
|
||||
event = PipelineAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.source_event_type == "platform.message.created"
|
||||
assert event.event_time == 1700000000
|
||||
assert event.data == {
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
}
|
||||
|
||||
def test_query_to_event_handles_missing_message_chain(self, mock_query):
|
||||
"""Test delivery context building when Query has no message_chain."""
|
||||
delattr(mock_query, "message_chain")
|
||||
|
||||
event = PipelineAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.delivery.reply_target == {"message_id": None}
|
||||
|
||||
|
||||
class TestPipelineConfigToBinding:
|
||||
"""Test Pipeline config -> AgentBinding conversion."""
|
||||
|
||||
@@ -11,10 +11,9 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
|
||||
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
from langbot.pkg.agent.runner.state_store import get_state_store, reset_state_store
|
||||
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -227,7 +226,6 @@ def make_query():
|
||||
|
||||
|
||||
def test_context_builder_includes_consumable_base64_attachments():
|
||||
builder = AgentRunContextBuilder(ap=types.SimpleNamespace())
|
||||
query = make_query()
|
||||
query.user_message = provider_message.Message(
|
||||
role="user",
|
||||
@@ -241,20 +239,15 @@ def test_context_builder_includes_consumable_base64_attachments():
|
||||
[platform_message.Image(base64="data:image/jpeg;base64,aGVsbG8=")]
|
||||
)
|
||||
|
||||
input_data = builder._build_input(query)
|
||||
attachments = input_data["attachments"]
|
||||
input_data = PipelineAdapter._build_input(query)
|
||||
|
||||
image_attachment = next(item for item in attachments if item["type"] == "image" and item["source"] == "base64")
|
||||
file_attachment = next(item for item in attachments if item["type"] == "file" and item["source"] == "base64")
|
||||
chain_attachment = next(item for item in attachments if item["source"] == "message_chain")
|
||||
assert input_data.contents[0].text == "see attached"
|
||||
assert input_data.contents[1].image_base64 == "data:image/png;base64,aGVsbG8="
|
||||
assert input_data.contents[2].file_base64 == "data:text/plain;base64,aGVsbG8="
|
||||
|
||||
assert image_attachment["content"] == "data:image/png;base64,aGVsbG8="
|
||||
assert image_attachment["content_type"] == "image/png"
|
||||
assert file_attachment["content"] == "data:text/plain;base64,aGVsbG8="
|
||||
assert file_attachment["content_type"] == "text/plain"
|
||||
assert file_attachment["name"] == "hello.txt"
|
||||
assert chain_attachment["content"] == "data:image/jpeg;base64,aGVsbG8="
|
||||
assert chain_attachment["content_type"] == "image/jpeg"
|
||||
artifact_types = [attachment.artifact_type for attachment in input_data.attachments]
|
||||
assert artifact_types == ["image", "file", "image"]
|
||||
assert input_data.attachments[1].name == "hello.txt"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -262,7 +255,6 @@ async def clean_agent_state():
|
||||
"""Reset all singleton stores and create a test database engine."""
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
reset_state_store()
|
||||
reset_persistent_state_store()
|
||||
registry = get_session_registry()
|
||||
for session in await registry.list_active_runs():
|
||||
@@ -280,7 +272,6 @@ async def clean_agent_state():
|
||||
# Cleanup
|
||||
for session in await registry.list_active_runs():
|
||||
await registry.unregister(session["run_id"])
|
||||
reset_state_store()
|
||||
reset_persistent_state_store()
|
||||
await test_engine.dispose()
|
||||
|
||||
@@ -378,7 +369,7 @@ async def test_orchestrator_packages_max_round_without_mutating_query(clean_agen
|
||||
"message 3",
|
||||
"response 3",
|
||||
]
|
||||
# Also in adapter.adapter_messages for transition runners
|
||||
# Also exposed in adapter.adapter_messages for runners that consume adapter bootstrap.
|
||||
assert [message["content"] for message in context["adapter"]["adapter_messages"]] == [
|
||||
"message 2",
|
||||
"response 2",
|
||||
@@ -453,10 +444,7 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert [message.content for message in messages] == ["state saved"]
|
||||
# Note: State is now persisted via PersistentStateStore, not in-memory RunnerScopedStateStore
|
||||
# The legacy behavior of updating query.session.using_conversation.uuid is no longer supported
|
||||
# when using event-first path via run_from_query() -> run()
|
||||
# Instead, state is persisted to the database via PersistentStateStore
|
||||
# State is persisted to the database via PersistentStateStore.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
|
||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
|
||||
|
||||
@@ -47,6 +48,16 @@ def make_query(runner_config: dict, *, variables: dict | None = None, use_llm_mo
|
||||
},
|
||||
variables=variables or {},
|
||||
use_llm_model_uuid=use_llm_model_uuid,
|
||||
pipeline_uuid='pipeline_001',
|
||||
)
|
||||
|
||||
|
||||
async def build_resources(app, query, descriptor):
|
||||
binding = PipelineAdapter.pipeline_config_to_binding(query, descriptor.id)
|
||||
return await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
event=Mock(),
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +104,7 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources(query, descriptor)
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == [
|
||||
{'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider'},
|
||||
@@ -120,7 +131,7 @@ async def test_build_models_still_honors_manifest_permissions(app):
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources(query, descriptor)
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['models'] == []
|
||||
app.model_mgr.get_model_by_uuid.assert_not_awaited()
|
||||
@@ -143,6 +154,6 @@ async def test_build_models_deduplicates_query_and_config_models(app):
|
||||
use_llm_model_uuid='primary',
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources(query, descriptor)
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert [model['model_id'] for model in resources['models']] == ['primary', 'fallback']
|
||||
|
||||
@@ -242,6 +242,7 @@ class TestNormalizeNonMessageResults:
|
||||
result_dict = {
|
||||
'type': 'state.updated',
|
||||
'data': {
|
||||
'scope': 'conversation',
|
||||
'key': 'external_conversation_id',
|
||||
'value': 'abc123',
|
||||
},
|
||||
@@ -340,4 +341,4 @@ class TestNormalizeInvalidResults:
|
||||
},
|
||||
}
|
||||
result = await normalizer.normalize(result_dict, descriptor)
|
||||
assert result is None
|
||||
assert result is None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user