mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-23 04:46:07 +00:00
435 lines
16 KiB
Python
435 lines
16 KiB
Python
"""Tests for event-first Protocol v1 entities and Query entry adapter.
|
|
|
|
Tests cover:
|
|
1. Query -> AgentEventEnvelope conversion
|
|
2. Current config -> AgentConfig projection and single-binding resolution
|
|
3. AgentRunContext not inlining full history by default
|
|
4. LangBot Host not defining context-window controls
|
|
5. Event-first run() entry point
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import Mock
|
|
|
|
# Import SDK entities
|
|
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
|
AgentEventContext,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
|
|
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
|
|
from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
|
AgentRunResult,
|
|
)
|
|
|
|
# Import LangBot host models
|
|
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
|
from langbot.pkg.agent.runner.binding_resolver import (
|
|
AgentBindingResolver,
|
|
AgentBindingResolutionError,
|
|
)
|
|
|
|
|
|
class TestQueryToEventEnvelope:
|
|
"""Test Query -> AgentEventEnvelope conversion."""
|
|
|
|
def test_query_to_event_basic_fields(self, mock_query):
|
|
"""Test basic field conversion from Query to Event envelope."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.event_type == 'message.received'
|
|
assert event.source == 'host_adapter'
|
|
assert event.bot_id == mock_query.bot_uuid
|
|
assert event.actor is not None
|
|
assert event.actor.actor_type == 'user'
|
|
|
|
def test_query_to_event_input(self, mock_query):
|
|
"""Test input conversion from Query."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.input is not None
|
|
assert event.input.text == 'Hello world'
|
|
assert 'message_chain' not in event.input.model_dump()
|
|
|
|
def test_query_to_event_conversation(self, mock_query):
|
|
"""Test conversation context extraction."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.conversation_id == 'conv-uuid-123'
|
|
|
|
def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query):
|
|
"""Pipeline variables can provide the conversation identity for state scope."""
|
|
mock_query.session.using_conversation.uuid = None
|
|
mock_query.variables['conversation_id'] = 'conv-from-vars'
|
|
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.conversation_id == 'conv-from-vars'
|
|
|
|
def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query):
|
|
"""Debug Chat and legacy pipeline runs may not have a conversation UUID."""
|
|
mock_query.session.using_conversation.uuid = None
|
|
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.conversation_id == 'person_launcher-123'
|
|
|
|
def test_query_to_event_delivery_context(self, mock_query):
|
|
"""Test delivery context extraction."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.delivery is not None
|
|
assert event.delivery.surface == 'platform'
|
|
assert isinstance(event.delivery.supports_streaming, bool)
|
|
assert event.delivery.reply_target == {
|
|
'target_type': 'person',
|
|
'target_id': 'launcher-123',
|
|
'message_id': 789,
|
|
}
|
|
|
|
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 = QueryEntryAdapter.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',
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
('source_time', 'expected'),
|
|
[
|
|
(1_700_000_000, 1_700_000_000),
|
|
(1_700_000_000_123, 1_700_000_000),
|
|
(1_700_000_000_123_456, 1_700_000_000),
|
|
],
|
|
)
|
|
def test_query_to_event_normalizes_legacy_timestamp_units(
|
|
self,
|
|
mock_query,
|
|
source_time,
|
|
expected,
|
|
):
|
|
mock_query.message_event = Mock(
|
|
type='platform.message.created',
|
|
time=source_time,
|
|
sender=None,
|
|
)
|
|
mock_query.message_event.model_dump = Mock(return_value={})
|
|
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.event_time == expected
|
|
|
|
def test_query_to_event_keeps_large_payloads_out_of_event_data(self, mock_query):
|
|
"""Large or nested platform payloads should not be duplicated into event.data."""
|
|
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',
|
|
'message_chain': [{'type': 'Image', 'base64': 'data:image/png;base64,' + ('a' * 1024)}],
|
|
'raw_text': 'x' * 1024,
|
|
'source_platform_object': {'large': 'payload'},
|
|
}
|
|
)
|
|
mock_query.message_event = source_event
|
|
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
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 = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert event.delivery.reply_target == {
|
|
'target_type': 'person',
|
|
'target_id': 'launcher-123',
|
|
'message_id': None,
|
|
}
|
|
|
|
def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query):
|
|
"""Pipeline-local message IDs must not become global audit IDs."""
|
|
first = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
mock_query.launcher_id = 'launcher-456'
|
|
second = QueryEntryAdapter.query_to_event(mock_query)
|
|
|
|
assert first.event_id.startswith('host:')
|
|
assert first.event_id != '789'
|
|
assert second.event_id != first.event_id
|
|
|
|
|
|
class TestQueryConfigToAgentConfig:
|
|
"""Test current config projection and single-Agent binding resolution."""
|
|
|
|
def test_config_to_agent_config_runner_id(self, mock_query):
|
|
"""Test AgentConfig runner_id extraction."""
|
|
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:author/plugin/runner')
|
|
|
|
assert agent_config.runner_id == 'plugin:author/plugin/runner'
|
|
assert agent_config.processor_type == 'pipeline'
|
|
assert agent_config.processor_id == mock_query.pipeline_uuid
|
|
assert agent_config.delivery_policy.enable_interactions is True
|
|
|
|
def test_config_to_agent_config_uses_current_runner_config(self, mock_query):
|
|
"""Temporary query adapters use the current runner config container."""
|
|
mock_query.pipeline_config = {
|
|
'ai': {
|
|
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
|
|
'runner_config': {
|
|
'plugin:langbot-team/LocalAgent/default': {
|
|
'model': {'primary': 'model-primary', 'fallbacks': []},
|
|
'knowledge-bases': ['kb-001'],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
agent_config = QueryEntryAdapter.config_to_agent_config(
|
|
mock_query,
|
|
'plugin:langbot-team/LocalAgent/default',
|
|
)
|
|
|
|
assert agent_config.runner_config['model'] == {'primary': 'model-primary', 'fallbacks': []}
|
|
assert agent_config.runner_config['knowledge-bases'] == ['kb-001']
|
|
|
|
def test_resolver_projects_agent_scope(self, mock_query):
|
|
"""Test binding scope projection through the resolver."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
|
|
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
|
|
|
assert binding.scope.scope_type == 'agent'
|
|
assert binding.scope.scope_id == mock_query.pipeline_uuid
|
|
assert binding.agent_id == mock_query.pipeline_uuid
|
|
assert binding.processor_type == 'pipeline'
|
|
assert binding.processor_id == mock_query.pipeline_uuid
|
|
|
|
def test_interaction_submission_projects_control_event(self, mock_query):
|
|
"""Pipeline callbacks keep structured submission data and match the runner binding."""
|
|
mock_query.variables = {
|
|
'_interaction_submission': {
|
|
'interaction_id': 'form-1',
|
|
'action_id': 'approve',
|
|
'values': {'name': 'Alice'},
|
|
}
|
|
}
|
|
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:langbot-team/DifyAgent/default')
|
|
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
|
|
|
assert event.event_type == 'interaction.submitted'
|
|
assert event.data['interaction']['values'] == {'name': 'Alice'}
|
|
assert event.subject.subject_type == 'interaction'
|
|
assert agent_config.event_types == ['interaction.submitted']
|
|
assert binding.processor_type == 'pipeline'
|
|
|
|
def test_resolver_rejects_multiple_matching_agents(self, mock_query):
|
|
"""Event dispatch is single-Agent in v1."""
|
|
event = QueryEntryAdapter.query_to_event(mock_query)
|
|
first = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
|
|
second = first.model_copy(update={'agent_id': 'agent_2'})
|
|
|
|
with pytest.raises(AgentBindingResolutionError):
|
|
AgentBindingResolver().resolve_one(event, [first, second])
|
|
|
|
|
|
class TestAgentRunContextProtocolV1:
|
|
"""Test AgentRunContext Protocol v1 behavior."""
|
|
|
|
def test_sdk_context_event_required(self):
|
|
"""Test that event is required in Protocol v1 context."""
|
|
trigger = AgentTrigger(type='message.received')
|
|
event = AgentEventContext(
|
|
event_id='evt_1',
|
|
event_type='message.received',
|
|
source='platform',
|
|
)
|
|
input = AgentInput(text='Hello')
|
|
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
|
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
|
|
|
ctx = AgentRunContext(
|
|
run_id='run_1',
|
|
trigger=trigger,
|
|
event=event,
|
|
input=input,
|
|
delivery=DeliveryContext(surface='platform'),
|
|
resources=AgentResources(),
|
|
runtime=AgentRuntimeContext(),
|
|
)
|
|
|
|
assert ctx.event is not None
|
|
assert ctx.event.event_type == 'message.received'
|
|
|
|
def test_sdk_context_has_no_history_message_fields(self):
|
|
"""AgentRunContext should not expose inline history message fields."""
|
|
trigger = AgentTrigger(type='message.received')
|
|
event = AgentEventContext(
|
|
event_id='evt_1',
|
|
event_type='message.received',
|
|
source='platform',
|
|
)
|
|
input = AgentInput(text='Hello')
|
|
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
|
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
|
|
|
ctx = AgentRunContext(
|
|
run_id='run_1',
|
|
trigger=trigger,
|
|
event=event,
|
|
input=input,
|
|
delivery=DeliveryContext(surface='platform'),
|
|
resources=AgentResources(),
|
|
runtime=AgentRuntimeContext(),
|
|
)
|
|
|
|
assert 'messages' not in AgentRunContext.model_fields
|
|
assert 'bootstrap' not in AgentRunContext.model_fields
|
|
assert not hasattr(ctx, 'bootstrap')
|
|
|
|
|
|
class TestHostManagedHistoryNotInProtocol:
|
|
"""Test that Host-managed history payloads are not in Protocol v1."""
|
|
|
|
def test_messages_not_in_sdk_context_top_level(self):
|
|
"""AgentRunContext should not expose top-level history messages."""
|
|
ctx_fields = AgentRunContext.model_fields.keys()
|
|
|
|
assert 'messages' not in ctx_fields
|
|
|
|
|
|
class TestSDKResultProtocolV1:
|
|
"""Test SDK AgentRunResult for Protocol v1."""
|
|
|
|
def test_result_requires_run_id(self):
|
|
"""Test result requires run_id for Protocol v1."""
|
|
from langbot_plugin.api.entities.builtin.provider.message import Message
|
|
|
|
result = AgentRunResult.message_completed(
|
|
run_id='run_1',
|
|
message=Message(role='assistant', content='Hello'),
|
|
)
|
|
|
|
assert result.run_id == 'run_1'
|
|
|
|
|
|
# Fixtures
|
|
@pytest.fixture
|
|
def mock_query():
|
|
"""Create a mock query for testing."""
|
|
query = Mock()
|
|
query.query_id = 123
|
|
query.bot_uuid = 'bot-uuid-123'
|
|
query.pipeline_uuid = 'pipeline-uuid-456'
|
|
query.launcher_type = Mock(value='person')
|
|
query.launcher_id = 'launcher-123'
|
|
query.sender_id = 'sender-123'
|
|
query.pipeline_config = {
|
|
'ai': {
|
|
'runner': 'plugin:test/plugin/runner',
|
|
}
|
|
}
|
|
query.variables = {}
|
|
|
|
# Create a proper content element mock
|
|
content_elem = Mock(spec=['type', 'text', 'model_dump'])
|
|
content_elem.type = 'text'
|
|
content_elem.text = 'Hello world'
|
|
content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Hello world'})
|
|
|
|
query.user_message = Mock()
|
|
query.user_message.content = [content_elem]
|
|
|
|
# Create message_chain mock
|
|
message_chain = Mock()
|
|
message_chain.message_id = 789
|
|
message_chain.model_dump = Mock(return_value={'message_id': 789, 'components': []})
|
|
query.message_chain = message_chain
|
|
|
|
query.message_event = None
|
|
|
|
# Mock session with proper conversation
|
|
query.session = Mock()
|
|
query.session.launcher_type = Mock(value='person')
|
|
query.session.launcher_id = 'launcher-123'
|
|
query.session.using_conversation = Mock()
|
|
query.session.using_conversation.uuid = 'conv-uuid-123'
|
|
|
|
# Mock use_funcs (empty list by default)
|
|
query.use_funcs = []
|
|
query.use_llm_model_uuid = None
|
|
|
|
return query
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_query_no_session():
|
|
"""Create a mock Query without session."""
|
|
query = Mock()
|
|
query.query_id = 456
|
|
query.bot_uuid = 'bot-uuid-456'
|
|
query.pipeline_uuid = 'pipeline-uuid-789'
|
|
query.launcher_type = Mock(value='person')
|
|
query.launcher_id = 'launcher-456'
|
|
query.sender_id = 'sender-456'
|
|
query.pipeline_config = {
|
|
'ai': {
|
|
'runner': 'plugin:test/plugin/runner',
|
|
}
|
|
}
|
|
query.variables = {}
|
|
|
|
# Create a proper content element mock
|
|
content_elem = Mock(spec=['type', 'text', 'model_dump'])
|
|
content_elem.type = 'text'
|
|
content_elem.text = 'Test message'
|
|
content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Test message'})
|
|
|
|
query.user_message = Mock()
|
|
query.user_message.content = [content_elem]
|
|
|
|
message_chain = Mock()
|
|
message_chain.message_id = -1
|
|
message_chain.model_dump = Mock(return_value={'message_id': -1, 'components': []})
|
|
query.message_chain = message_chain
|
|
|
|
query.message_event = None
|
|
query.session = None
|
|
|
|
# Mock use_funcs
|
|
query.use_funcs = []
|
|
query.use_llm_model_uuid = None
|
|
|
|
return query
|