mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 04:24:36 +00:00
Add EventLog and Transcript persistence entities for storing auditable event facts and conversation history projection. Implement event-first AgentRunContext builder that produces Protocol v1 compliant context payloads with required fields: event, delivery, context (ContextAccess). Key changes: - EventLog ORM: auditable event records with indexes - Transcript ORM: conversation history projection with composite indexes - AgentRunContextBuilder: Protocol v1 payload with delivery, context, bootstrap - EventLogStore/TranscriptStore: async stores for fact sources - Host action handlers: HISTORY_PAGE, HISTORY_SEARCH, EVENT_GET, EVENT_PAGE - Context validation: build_context output validates via SDK AgentRunContext - Alembic migration for event_log and transcript tables - Alembic env.py imports all ORM models for autogenerate discovery Legacy compatibility: max-round messages go into bootstrap.messages and compatibility.legacy_messages, not top-level messages field.
432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""Tests for event-first Protocol v1 entities and Pipeline compatibility adapter.
|
|
|
|
Tests cover:
|
|
1. Pipeline Query -> AgentEventEnvelope conversion
|
|
2. Pipeline config -> AgentBinding conversion
|
|
3. AgentRunContext not inlining full history by default
|
|
4. Legacy max-round only affecting bootstrap/compat adapter
|
|
5. Event-first run() entry point
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
import typing
|
|
|
|
# Import SDK entities
|
|
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
|
AgentEventContext,
|
|
ConversationContext,
|
|
ActorContext,
|
|
SubjectContext,
|
|
)
|
|
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,
|
|
AgentRunResultType,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.capabilities import (
|
|
AgentRunnerCapabilities,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
|
|
AgentRunnerPermissions,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.context_policy import (
|
|
AgentRunnerContextPolicy,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
|
AgentRunnerManifest,
|
|
)
|
|
|
|
# Import LangBot host models
|
|
from langbot.pkg.agent.runner.host_models import (
|
|
AgentEventEnvelope,
|
|
AgentBinding,
|
|
BindingScope,
|
|
ResourcePolicy,
|
|
StatePolicy,
|
|
DeliveryPolicy,
|
|
)
|
|
from langbot.pkg.agent.runner.pipeline_compat_adapter import PipelineCompatAdapter
|
|
|
|
|
|
class TestPipelineQueryToEventEnvelope:
|
|
"""Test Pipeline Query -> AgentEventEnvelope conversion."""
|
|
|
|
def test_query_to_event_basic_fields(self, mock_query):
|
|
"""Test basic field conversion from Query to Event envelope."""
|
|
event = PipelineCompatAdapter.query_to_event(mock_query)
|
|
|
|
assert event.event_type == "message.received"
|
|
assert event.source == "pipeline_compat"
|
|
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 = PipelineCompatAdapter.query_to_event(mock_query)
|
|
|
|
assert event.input is not None
|
|
assert event.input.text == "Hello world"
|
|
|
|
def test_query_to_event_conversation(self, mock_query):
|
|
"""Test conversation context extraction."""
|
|
event = PipelineCompatAdapter.query_to_event(mock_query)
|
|
|
|
# Conversation may be None if no session
|
|
if event.conversation_id:
|
|
assert event.conversation_id is not None
|
|
|
|
def test_query_to_event_delivery_context(self, mock_query):
|
|
"""Test delivery context extraction."""
|
|
event = PipelineCompatAdapter.query_to_event(mock_query)
|
|
|
|
assert event.delivery is not None
|
|
assert event.delivery.surface == "platform"
|
|
assert isinstance(event.delivery.supports_streaming, bool)
|
|
|
|
|
|
class TestPipelineConfigToBinding:
|
|
"""Test Pipeline config -> AgentBinding conversion."""
|
|
|
|
def test_config_to_binding_runner_id(self, mock_query):
|
|
"""Test binding runner_id extraction."""
|
|
binding = PipelineCompatAdapter.pipeline_config_to_binding(
|
|
mock_query, "plugin:author/plugin/runner"
|
|
)
|
|
|
|
assert binding.runner_id == "plugin:author/plugin/runner"
|
|
|
|
def test_config_to_binding_scope(self, mock_query):
|
|
"""Test binding scope extraction."""
|
|
binding = PipelineCompatAdapter.pipeline_config_to_binding(
|
|
mock_query, "plugin:test/plugin/runner"
|
|
)
|
|
|
|
assert binding.scope.scope_type == "pipeline"
|
|
assert binding.scope.scope_id == mock_query.pipeline_uuid
|
|
|
|
def test_config_to_binding_max_round(self, mock_query_with_max_round):
|
|
"""Test max_round extraction for compatibility adapter."""
|
|
binding = PipelineCompatAdapter.pipeline_config_to_binding(
|
|
mock_query_with_max_round, "plugin:test/plugin/runner"
|
|
)
|
|
|
|
# max_round should be captured but NOT in Protocol v1 entities
|
|
assert binding.max_round == 10
|
|
|
|
def test_config_to_binding_no_max_round(self, mock_query):
|
|
"""Test binding without max_round."""
|
|
binding = PipelineCompatAdapter.pipeline_config_to_binding(
|
|
mock_query, "plugin:test/plugin/runner"
|
|
)
|
|
|
|
# max_round may be None
|
|
assert binding.max_round is None
|
|
|
|
|
|
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_messages_default_empty(self):
|
|
"""Test that messages default to empty (not full history)."""
|
|
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(),
|
|
)
|
|
|
|
# messages is now in bootstrap, not top-level
|
|
assert ctx.bootstrap is None or ctx.bootstrap.messages == []
|
|
|
|
def test_sdk_context_bootstrap_optional(self):
|
|
"""Test that bootstrap is optional."""
|
|
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(),
|
|
)
|
|
|
|
# bootstrap is optional
|
|
assert ctx.bootstrap is None or isinstance(ctx.bootstrap.messages, list)
|
|
|
|
|
|
class TestLegacyMaxRoundNotInProtocol:
|
|
"""Test that legacy max-round only affects compat adapter, not Protocol v1."""
|
|
|
|
def test_max_round_not_in_sdk_context(self):
|
|
"""Test max-round is not a field in SDK AgentRunContext."""
|
|
# AgentRunContext should not have max_round field
|
|
ctx_fields = AgentRunContext.model_fields.keys()
|
|
|
|
assert "max_round" not in ctx_fields
|
|
assert "maxRound" not in ctx_fields
|
|
|
|
def test_max_round_in_compatibility_context(self):
|
|
"""Test max_round is in compatibility context, not main 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
|
|
from langbot_plugin.api.entities.builtin.agent_runner.context import CompatibilityContext
|
|
|
|
compat = CompatibilityContext(max_round=10)
|
|
|
|
ctx = AgentRunContext(
|
|
run_id="run_1",
|
|
trigger=trigger,
|
|
event=event,
|
|
input=input,
|
|
delivery=DeliveryContext(surface="platform"),
|
|
resources=AgentResources(),
|
|
runtime=AgentRuntimeContext(),
|
|
compatibility=compat,
|
|
)
|
|
|
|
# max_round is in compatibility context, not main context
|
|
assert ctx.compatibility is not None
|
|
assert ctx.compatibility.max_round == 10
|
|
|
|
def test_binding_max_round_for_adapter_only(self, mock_query_with_max_round):
|
|
"""Test max_round in binding is for adapter use, not Protocol v1."""
|
|
binding = PipelineCompatAdapter.pipeline_config_to_binding(
|
|
mock_query_with_max_round, "plugin:test/plugin/runner"
|
|
)
|
|
|
|
# max_round is in binding (Host-internal) for compat adapter
|
|
assert binding.max_round == 10
|
|
|
|
# But SDK entities don't have it
|
|
ctx_fields = AgentRunContext.model_fields.keys()
|
|
assert "max_round" not in ctx_fields
|
|
|
|
|
|
class TestSDKCapabilitiesProtocolV1:
|
|
"""Test SDK capabilities for Protocol v1."""
|
|
|
|
def test_self_managed_context_default_true(self):
|
|
"""Test self_managed_context defaults to True for Protocol v1."""
|
|
caps = AgentRunnerCapabilities()
|
|
|
|
assert caps.self_managed_context is True
|
|
|
|
def test_event_context_default_true(self):
|
|
"""Test event_context defaults to True for Protocol v1."""
|
|
caps = AgentRunnerCapabilities()
|
|
|
|
assert caps.event_context is True
|
|
|
|
|
|
class TestSDKPermissionsProtocolV1:
|
|
"""Test SDK permissions for Protocol v1."""
|
|
|
|
def test_permissions_new_fields(self):
|
|
"""Test new permission fields for Protocol v1."""
|
|
perms = AgentRunnerPermissions(
|
|
models=["invoke", "stream", "rerank"],
|
|
tools=["detail", "call"],
|
|
knowledge_bases=["list", "retrieve"],
|
|
history=["page", "search"],
|
|
events=["get", "page"],
|
|
artifacts=["metadata", "read"],
|
|
storage=["plugin", "workspace", "binding"],
|
|
)
|
|
|
|
assert perms.history == ["page", "search"]
|
|
assert perms.events == ["get", "page"]
|
|
assert perms.artifacts == ["metadata", "read"]
|
|
assert perms.storage == ["plugin", "workspace", "binding"]
|
|
|
|
|
|
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"
|
|
|
|
def test_artifact_created_result_type(self):
|
|
"""Test artifact.created result type."""
|
|
result = AgentRunResult.artifact_created(
|
|
run_id="run_1",
|
|
artifact_id="artifact_1",
|
|
artifact_type="image",
|
|
)
|
|
|
|
assert result.type == AgentRunResultType.ARTIFACT_CREATED
|
|
assert result.data["artifact_id"] == "artifact_1"
|
|
|
|
|
|
# Fixtures
|
|
@pytest.fixture
|
|
def mock_query():
|
|
"""Create a mock Pipeline 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_with_max_round(mock_query):
|
|
"""Create a mock Query with max_round configuration."""
|
|
mock_query.pipeline_config = {
|
|
"ai": {
|
|
"runner": "plugin:test/plugin/runner",
|
|
"max-round": 10,
|
|
}
|
|
}
|
|
return mock_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
|