mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-23 04:46:07 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +32,7 @@ class TestContextValidation:
|
||||
"""Create a mock application."""
|
||||
mock_app = MagicMock(spec=app.Application)
|
||||
mock_app.ver_mgr = MagicMock()
|
||||
mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0")
|
||||
mock_app.ver_mgr.get_current_version = MagicMock(return_value='1.0.0')
|
||||
mock_app.persistence_mgr = MagicMock()
|
||||
mock_app.persistence_mgr.get_db_engine = MagicMock()
|
||||
mock_app.logger = MagicMock()
|
||||
@@ -44,35 +45,35 @@ class TestContextValidation:
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
event_time=1700000000,
|
||||
source="platform",
|
||||
source_event_type="platform.message",
|
||||
bot_id="bot_1",
|
||||
workspace_id="workspace_1",
|
||||
conversation_id="conv_1",
|
||||
source='platform',
|
||||
source_event_type='platform.message',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
conversation_id='conv_1',
|
||||
thread_id=None,
|
||||
actor=ActorContext(
|
||||
actor_type="user",
|
||||
actor_id="user_1",
|
||||
actor_name="Test User",
|
||||
actor_type='user',
|
||||
actor_id='user_1',
|
||||
actor_name='Test User',
|
||||
),
|
||||
subject=None,
|
||||
input=EventInput(text="Hello world"),
|
||||
delivery=DeliveryContext(surface="test"),
|
||||
data={"platform_event_id": "source_evt_1"},
|
||||
input=EventInput(text='Hello world'),
|
||||
delivery=DeliveryContext(surface='test'),
|
||||
data={'platform_event_id': 'source_evt_1'},
|
||||
)
|
||||
|
||||
def _make_binding(self) -> AgentBinding:
|
||||
"""Create a test binding."""
|
||||
return AgentBinding(
|
||||
binding_id="binding_1",
|
||||
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
|
||||
event_types=["message.received"],
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
runner_config={"timeout": 300},
|
||||
agent_id="pipeline_1",
|
||||
binding_id='binding_1',
|
||||
scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
|
||||
event_types=['message.received'],
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
runner_config={'timeout': 300},
|
||||
agent_id='pipeline_1',
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@@ -91,16 +92,16 @@ class TestContextValidation:
|
||||
def _make_descriptor(self):
|
||||
"""Create a mock runner descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
id="plugin:test/plugin/runner",
|
||||
source="plugin",
|
||||
label={"en_US": "Test Runner"},
|
||||
plugin_author="test",
|
||||
plugin_name="plugin",
|
||||
runner_name="runner",
|
||||
id='plugin:test/plugin/runner',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
plugin_author='test',
|
||||
plugin_name='plugin',
|
||||
runner_name='runner',
|
||||
permissions={
|
||||
"history": ["page", "search"],
|
||||
"events": ["get", "page"],
|
||||
"storage": ["plugin", "workspace"],
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'storage': ['plugin', 'workspace'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -118,12 +119,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
# Build context
|
||||
@@ -152,27 +155,69 @@ class TestContextValidation:
|
||||
assert isinstance(validated.resources, AgentResources)
|
||||
assert validated.runtime is not None
|
||||
assert isinstance(validated.runtime, AgentRuntimeContext)
|
||||
assert "protocol_version" not in validated.runtime.model_dump()
|
||||
assert "sdk_protocol_version" not in validated.runtime.model_dump()
|
||||
assert "sdk_protocol_version" not in context_dict["runtime"]
|
||||
assert 'protocol_version' not in validated.runtime.model_dump()
|
||||
assert 'sdk_protocol_version' not in validated.runtime.model_dump()
|
||||
assert 'sdk_protocol_version' not in context_dict['runtime']
|
||||
|
||||
# Verify event context
|
||||
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"}
|
||||
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"
|
||||
assert validated.conversation.bot_id == 'bot_1'
|
||||
assert validated.conversation.workspace_id == 'workspace_1'
|
||||
|
||||
# Verify delivery context
|
||||
assert validated.delivery.surface == "test"
|
||||
assert validated.delivery.surface == 'test'
|
||||
|
||||
# Verify input
|
||||
assert validated.input.text == "Hello world"
|
||||
assert validated.input.text == 'Hello world'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_preserves_interaction_protocol_fields(self):
|
||||
"""Validated submissions and delivery capabilities survive the final context projection."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
|
||||
InteractionDeliveryCapabilities,
|
||||
InteractionSubmission,
|
||||
)
|
||||
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
event = self._make_event_envelope()
|
||||
event.event_type = 'interaction.submitted'
|
||||
event.input.interaction = InteractionSubmission(
|
||||
interaction_id='form-1',
|
||||
action_id='approve',
|
||||
values={'comment': 'looks good'},
|
||||
)
|
||||
event.delivery.interactions = InteractionDeliveryCapabilities(
|
||||
field_types=['text', 'select'],
|
||||
action_styles=['primary'],
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={'conversation': {}, 'actor': {}, 'subject': {}, 'runner': {}}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
context_dict = await builder.build_context_from_event(
|
||||
event=event,
|
||||
binding=self._make_binding(),
|
||||
descriptor=self._make_descriptor(),
|
||||
resources=self._make_resources(),
|
||||
)
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
assert validated.input.interaction is not None
|
||||
assert validated.input.interaction.interaction_id == 'form-1'
|
||||
assert validated.input.interaction.values == {'comment': 'looks good'}
|
||||
assert validated.delivery.interactions is not None
|
||||
assert validated.delivery.interactions.field_types == ['text', 'select']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_populates_model_context_window(self):
|
||||
@@ -207,12 +252,14 @@ class TestContextValidation:
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -265,37 +312,39 @@ class TestContextValidation:
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
event = AgentEventEnvelope(
|
||||
event_id="evt_recall_1",
|
||||
event_type="message.recalled",
|
||||
event_id='evt_recall_1',
|
||||
event_type='message.recalled',
|
||||
event_time=1700000001,
|
||||
source="platform",
|
||||
source_event_type="platform.message.recall",
|
||||
bot_id="bot_1",
|
||||
workspace_id="workspace_1",
|
||||
conversation_id="conv_1",
|
||||
actor=ActorContext(actor_type="user", actor_id="user_1"),
|
||||
source='platform',
|
||||
source_event_type='platform.message.recall',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
conversation_id='conv_1',
|
||||
actor=ActorContext(actor_type='user', actor_id='user_1'),
|
||||
subject=SubjectContext(
|
||||
subject_type="message",
|
||||
subject_id="message_1",
|
||||
data={"recalled_message_id": "message_1", "reason": "user_recall"},
|
||||
subject_type='message',
|
||||
subject_id='message_1',
|
||||
data={'recalled_message_id': 'message_1', 'reason': 'user_recall'},
|
||||
),
|
||||
input=EventInput(text=None),
|
||||
delivery=DeliveryContext(surface="test"),
|
||||
data={"source_event_id": "source_recall_1"},
|
||||
delivery=DeliveryContext(surface='test'),
|
||||
data={'source_event_id': 'source_recall_1'},
|
||||
)
|
||||
binding = self._make_binding()
|
||||
binding.event_types = ["message.recalled"]
|
||||
binding.event_types = ['message.recalled']
|
||||
resources = self._make_resources()
|
||||
descriptor = self._make_descriptor()
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -307,12 +356,12 @@ class TestContextValidation:
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
|
||||
assert validated.event.event_type == "message.recalled"
|
||||
assert validated.event.event_type == 'message.recalled'
|
||||
assert validated.input.text is None
|
||||
assert validated.subject is not None
|
||||
assert validated.subject.subject_type == "message"
|
||||
assert validated.subject.subject_id == "message_1"
|
||||
assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"}
|
||||
assert validated.subject.subject_type == 'message'
|
||||
assert validated.subject.subject_id == 'message_1'
|
||||
assert validated.subject.data == {'recalled_message_id': 'message_1', 'reason': 'user_recall'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
|
||||
@@ -328,12 +377,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -344,16 +395,16 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# Protocol v1 does NOT have these as core fields
|
||||
assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1"
|
||||
assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1"
|
||||
assert 'params' not in context_dict, "params should not be top-level in Protocol v1"
|
||||
assert 'messages' not in context_dict, 'messages should not be top-level in Protocol v1'
|
||||
assert 'prompt' not in context_dict, 'prompt should not be top-level in Protocol v1'
|
||||
assert 'params' not in context_dict, 'params should not be top-level in Protocol v1'
|
||||
|
||||
# Protocol v1 DOES have these
|
||||
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1"
|
||||
assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1"
|
||||
assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows"
|
||||
assert 'adapter' in context_dict, "adapter should exist"
|
||||
assert 'metadata' in context_dict, "metadata should exist"
|
||||
assert 'delivery' in context_dict, 'delivery is REQUIRED in Protocol v1'
|
||||
assert 'context' in context_dict, 'context (ContextAccess) is REQUIRED in Protocol v1'
|
||||
assert 'bootstrap' not in context_dict, 'Host must not inline bootstrap/history windows'
|
||||
assert 'adapter' in context_dict, 'adapter should exist'
|
||||
assert 'metadata' in context_dict, 'metadata should exist'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_event_is_not_none(self):
|
||||
@@ -369,12 +420,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -385,7 +438,7 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# event is REQUIRED in Protocol v1
|
||||
assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1"
|
||||
assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
@@ -405,12 +458,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -421,7 +476,7 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# delivery is REQUIRED in Protocol v1
|
||||
assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1"
|
||||
assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
4. LangBot Host not defining context-window controls
|
||||
5. Event-first run() entry point
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -38,34 +39,34 @@ class TestQueryToEventEnvelope:
|
||||
"""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.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"
|
||||
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()
|
||||
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"
|
||||
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"
|
||||
mock_query.variables['conversation_id'] = 'conv-from-vars'
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.conversation_id == "conv-from-vars"
|
||||
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."""
|
||||
@@ -73,77 +74,115 @@ class TestQueryToEventEnvelope:
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.conversation_id == "person_launcher-123"
|
||||
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 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.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"},
|
||||
})
|
||||
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.source_event_type == 'platform.message.created'
|
||||
assert event.event_time == 1700000000
|
||||
assert event.data == {
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
'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.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"},
|
||||
})
|
||||
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",
|
||||
'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")
|
||||
delattr(mock_query, 'message_chain')
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.delivery.reply_target == {"message_id": None}
|
||||
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"
|
||||
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 first.event_id.startswith('host:')
|
||||
assert first.event_id != '789'
|
||||
assert second.event_id != first.event_id
|
||||
|
||||
|
||||
@@ -152,21 +191,22 @@ class TestQueryConfigToAgentConfig:
|
||||
|
||||
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"
|
||||
)
|
||||
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.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"],
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
|
||||
'runner_config': {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
'model': {'primary': 'model-primary', 'fallbacks': []},
|
||||
'knowledge-bases': ['kb-001'],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -174,90 +214,109 @@ class TestQueryConfigToAgentConfig:
|
||||
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query,
|
||||
"plugin:langbot-team/LocalAgent/default",
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
|
||||
assert agent_config.runner_config["model"] == {"primary": "model-primary", "fallbacks": []}
|
||||
assert agent_config.runner_config["knowledge-bases"] == ["kb-001"]
|
||||
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"
|
||||
)
|
||||
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_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"})
|
||||
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")
|
||||
trigger = AgentTrigger(type='message.received')
|
||||
event = AgentEventContext(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text="Hello")
|
||||
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",
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
input=input,
|
||||
delivery=DeliveryContext(surface="platform"),
|
||||
delivery=DeliveryContext(surface='platform'),
|
||||
resources=AgentResources(),
|
||||
runtime=AgentRuntimeContext(),
|
||||
)
|
||||
|
||||
assert ctx.event is not None
|
||||
assert ctx.event.event_type == "message.received"
|
||||
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")
|
||||
trigger = AgentTrigger(type='message.received')
|
||||
event = AgentEventContext(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text="Hello")
|
||||
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",
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
input=input,
|
||||
delivery=DeliveryContext(surface="platform"),
|
||||
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")
|
||||
assert 'messages' not in AgentRunContext.model_fields
|
||||
assert 'bootstrap' not in AgentRunContext.model_fields
|
||||
assert not hasattr(ctx, 'bootstrap')
|
||||
|
||||
|
||||
class TestHostManagedHistoryNotInProtocol:
|
||||
@@ -267,7 +326,7 @@ class TestHostManagedHistoryNotInProtocol:
|
||||
"""AgentRunContext should not expose top-level history messages."""
|
||||
ctx_fields = AgentRunContext.model_fields.keys()
|
||||
|
||||
assert "messages" not in ctx_fields
|
||||
assert 'messages' not in ctx_fields
|
||||
|
||||
|
||||
class TestSDKResultProtocolV1:
|
||||
@@ -278,11 +337,12 @@ class TestSDKResultProtocolV1:
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
result = AgentRunResult.message_completed(
|
||||
run_id="run_1",
|
||||
message=Message(role="assistant", content="Hello"),
|
||||
run_id='run_1',
|
||||
message=Message(role='assistant', content='Hello'),
|
||||
)
|
||||
|
||||
assert result.run_id == "run_1"
|
||||
assert result.run_id == 'run_1'
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
@@ -290,14 +350,14 @@ 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.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",
|
||||
'ai': {
|
||||
'runner': 'plugin:test/plugin/runner',
|
||||
}
|
||||
}
|
||||
query.variables = {}
|
||||
@@ -321,10 +381,10 @@ def mock_query():
|
||||
|
||||
# Mock session with proper conversation
|
||||
query.session = Mock()
|
||||
query.session.launcher_type = Mock(value="person")
|
||||
query.session.launcher_id = "launcher-123"
|
||||
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"
|
||||
query.session.using_conversation.uuid = 'conv-uuid-123'
|
||||
|
||||
# Mock use_funcs (empty list by default)
|
||||
query.use_funcs = []
|
||||
@@ -338,14 +398,14 @@ 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.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",
|
||||
'ai': {
|
||||
'runner': 'plugin:test/plugin/runner',
|
||||
}
|
||||
}
|
||||
query.variables = {}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
"""Tests for structured interaction authorization and delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
|
||||
InteractionDeliveryCapabilities,
|
||||
InteractionSubmission,
|
||||
)
|
||||
|
||||
from langbot.pkg.agent.runner.errors import RunnerProtocolError
|
||||
from langbot.pkg.agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
AgentEventEnvelope,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
)
|
||||
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
|
||||
from langbot.pkg.agent.runner.interaction_store import InteractionStore
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self, *, supports_interactions: bool, fail_updates: bool = False):
|
||||
self.supports_interactions = supports_interactions
|
||||
self.fail_updates = fail_updates
|
||||
self.actions: list[tuple[str, dict]] = []
|
||||
self.messages: list[tuple[str, str, object]] = []
|
||||
|
||||
def get_supported_apis(self):
|
||||
return ['interaction.request', 'interaction.acknowledge'] if self.supports_interactions else ['send_message']
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict):
|
||||
self.actions.append((action, params))
|
||||
if action == 'interaction.request' and params.get('update_target') and self.fail_updates:
|
||||
raise RuntimeError('update unavailable')
|
||||
update_target = params.get('update_target') or {}
|
||||
message_id = update_target.get('message_id') or f'message-{len(self.actions)}'
|
||||
card_id = update_target.get('card_id') or f'card-{len(self.actions)}'
|
||||
sequence = int(update_target.get('sequence') or 0) + (1 if update_target else 0)
|
||||
return {
|
||||
'ok': True,
|
||||
'message_id': message_id,
|
||||
'card_id': card_id,
|
||||
'sequence': sequence,
|
||||
'rich': True,
|
||||
}
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message_chain):
|
||||
self.messages.append((target_type, target_id, message_chain))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager.db"}', echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield InteractionStore(engine)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event():
|
||||
return AgentEventEnvelope(
|
||||
event_id='evt-1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor=ActorContext(actor_type='user', actor_id='user-1'),
|
||||
input=AgentInput(text='start'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={'target_type': 'group', 'target_id': 'chat-1'},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def binding():
|
||||
return AgentBinding(
|
||||
binding_id='binding-1',
|
||||
scope=BindingScope(scope_type='agent', scope_id='pipeline-1'),
|
||||
runner_id='plugin:test/ApprovalRunner/default',
|
||||
delivery_policy=DeliveryPolicy(enable_interactions=True),
|
||||
processor_type='pipeline',
|
||||
processor_id='pipeline-1',
|
||||
)
|
||||
|
||||
|
||||
def _descriptor(*, permitted: bool = True):
|
||||
return SimpleNamespace(
|
||||
id='plugin:test/ApprovalRunner/default',
|
||||
capabilities=SimpleNamespace(interactions=permitted),
|
||||
permissions=SimpleNamespace(interactions=['request'] if permitted else []),
|
||||
)
|
||||
|
||||
|
||||
def _result():
|
||||
return {
|
||||
'type': 'action.requested',
|
||||
'data': {
|
||||
'action': 'interaction.requested',
|
||||
'target': {'target_type': 'person', 'target_id': 'attacker-selected'},
|
||||
'payload': {
|
||||
'interaction_id': 'form-1',
|
||||
'kind': 'choice',
|
||||
'title': 'Approve request?',
|
||||
'actions': [
|
||||
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
|
||||
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
|
||||
],
|
||||
'fallback_text': 'Reply approve or reject.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_delivery_uses_frozen_target_and_persists_request(store, event, binding):
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
consumed = await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
|
||||
assert consumed is True
|
||||
assert adapter.actions[0][0] == 'interaction.request'
|
||||
params = adapter.actions[0][1]
|
||||
assert params['reply_target'] == {'target_type': 'group', 'target_id': 'chat-1'}
|
||||
assert params['callback_token']
|
||||
assert 'attacker-selected' not in str(params)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['processor_type'] == 'pipeline'
|
||||
assert record['processor_id'] == 'pipeline-1'
|
||||
assert record['actor_id'] == 'user-1'
|
||||
assert record['expires_at'] is not None
|
||||
assert 0 < record['expires_at'] - time.time() <= 30 * 60
|
||||
assert record['delivery_result']['message_id'] == 'message-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuous_interaction_reuses_submitted_platform_presentation(store, event, binding):
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True)
|
||||
|
||||
await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
first_token = adapter.actions[0][1]['callback_token']
|
||||
submitted = await manager.consume_callback(
|
||||
callback_token=first_token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
await manager.acknowledge_submission(submitted, adapter)
|
||||
|
||||
event.event_type = 'interaction.submitted'
|
||||
event.input.interaction = InteractionSubmission(
|
||||
interaction_id='form-1',
|
||||
action_id='approve',
|
||||
)
|
||||
second_result = _result()
|
||||
second_result['data']['payload']['interaction_id'] = 'form-2'
|
||||
await manager.handle_result(
|
||||
result_dict=second_result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-2',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
|
||||
assert [action for action, _ in adapter.actions] == [
|
||||
'interaction.request',
|
||||
'interaction.acknowledge',
|
||||
'interaction.request',
|
||||
]
|
||||
update_params = adapter.actions[-1][1]
|
||||
assert update_params['update_target']['message_id'] == 'message-1'
|
||||
assert update_params['update_target']['sequence'] == 1
|
||||
second_record = await store.get_request('run-2', 'form-2')
|
||||
assert second_record['replaces_interaction_id'] == 'form-1'
|
||||
assert second_record['delivery_result']['message_id'] == 'message-1'
|
||||
assert second_record['delivery_result']['sequence'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_update_failure_falls_back_to_new_presentation(store, event, binding):
|
||||
adapter = FakeAdapter(supports_interactions=True, fail_updates=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True)
|
||||
|
||||
await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
first_token = adapter.actions[0][1]['callback_token']
|
||||
await manager.consume_callback(
|
||||
callback_token=first_token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
event.input.interaction = InteractionSubmission(interaction_id='form-1', action_id='approve')
|
||||
second_result = _result()
|
||||
second_result['data']['payload']['interaction_id'] = 'form-2'
|
||||
await manager.handle_result(
|
||||
result_dict=second_result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-2',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
|
||||
update_attempt = adapter.actions[-2][1]
|
||||
fallback_attempt = adapter.actions[-1][1]
|
||||
assert update_attempt['update_target']['message_id'] == 'message-1'
|
||||
assert 'update_target' not in fallback_attempt
|
||||
assert fallback_attempt['callback_token'] == update_attempt['callback_token']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_scope_uses_frozen_delivery_conversation(store, event, binding):
|
||||
event.conversation_id = 'runner-owned-external-conversation'
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record['conversation_id'] == 'group_chat-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_without_interactions_receives_fallback_text(store, event, binding):
|
||||
adapter = FakeAdapter(supports_interactions=False)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
assert await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
|
||||
assert adapter.actions == []
|
||||
target_type, target_id, message_chain = adapter.messages[0]
|
||||
assert (target_type, target_id) == ('group', 'chat-1')
|
||||
assert str(message_chain) == 'Reply approve or reject.'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_without_interaction_permission_is_rejected(store, event, binding):
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
with pytest.raises(RunnerProtocolError, match='did not declare'):
|
||||
await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(permitted=False),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
assert await store.get_request('run-1', 'form-1') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binding_policy_can_disable_interactions(store, event, binding):
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
binding.delivery_policy.enable_interactions = False
|
||||
|
||||
with pytest.raises(RunnerProtocolError, match='delivery policy'):
|
||||
await manager.handle_result(
|
||||
result_dict=_result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('expires_at', 'error'),
|
||||
[
|
||||
(lambda now: 0, 'already expired'),
|
||||
(lambda now: now - 1, 'already expired'),
|
||||
(lambda now: now + 24 * 60 * 60 + 1, 'exceeds 24 hours'),
|
||||
],
|
||||
)
|
||||
async def test_interaction_expiry_is_bounded(store, event, binding, expires_at, error):
|
||||
result = _result()
|
||||
result['data']['payload']['expires_at'] = expires_at(int(time.time()))
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
with pytest.raises(RunnerProtocolError, match=error):
|
||||
await manager.handle_result(
|
||||
result_dict=result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_rejects_duplicate_protocol_ids(store, event, binding):
|
||||
result = _result()
|
||||
result['data']['payload']['actions'].append({'id': 'approve', 'label': 'Approve again'})
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
with pytest.raises(RunnerProtocolError, match='duplicate action IDs'):
|
||||
await manager.handle_result(
|
||||
result_dict=result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_request_payload_is_bounded(store, event, binding):
|
||||
result = _result()
|
||||
result['data']['payload']['fields'] = [
|
||||
{
|
||||
'id': f'field-{field_index}',
|
||||
'label': 'Field',
|
||||
'type': 'select',
|
||||
'options': [
|
||||
{
|
||||
'value': f'{field_index}-{option_index}',
|
||||
'label': 'x' * 512,
|
||||
}
|
||||
for option_index in range(100)
|
||||
],
|
||||
}
|
||||
for field_index in range(10)
|
||||
]
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
|
||||
with pytest.raises(RunnerProtocolError, match='exceeds 256 KiB'):
|
||||
await manager.handle_result(
|
||||
result_dict=result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_interaction_action_is_not_consumed(store, event, binding):
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
result = _result()
|
||||
result['data']['action'] = 'platform.message.delete'
|
||||
|
||||
assert not await manager.handle_result(
|
||||
result_dict=result,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
|
||||
)
|
||||
|
||||
|
||||
async def _deliver_interaction(store, event, binding, result=None):
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
await manager.handle_result(
|
||||
result_dict=result or _result(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=_descriptor(),
|
||||
run_id='run-1',
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
)
|
||||
return manager, adapter.actions[0][1]['callback_token']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_action_ref_resolves_persisted_action_and_ignores_forged_id(store, event, binding):
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding)
|
||||
|
||||
resumed = await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={
|
||||
'interaction_id': 'attacker-selected',
|
||||
'action_ref': 1,
|
||||
'values': {},
|
||||
},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
assert resumed['submission']['interaction_id'] == 'form-1'
|
||||
assert resumed['submission']['action_id'] == 'reject'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_submission_time_is_host_owned(store, event, binding):
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding)
|
||||
before = int(time.time())
|
||||
|
||||
resumed = await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'action_ref': 0, 'submitted_at': 1},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
assert before <= resumed['submission']['submitted_at'] <= int(time.time())
|
||||
assert resumed['submitted_at'] == resumed['submission']['submitted_at']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_option_refs_resolve_persisted_field_and_value(store, event, binding):
|
||||
result = _result()
|
||||
result['data']['payload']['fields'] = [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [
|
||||
{'value': 'normal', 'label': 'Normal'},
|
||||
{'value': 'urgent', 'label': 'Urgent'},
|
||||
],
|
||||
}
|
||||
]
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding, result)
|
||||
|
||||
resumed = await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'field_ref': 0, 'option_ref': 1},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
assert resumed['submission']['interaction_id'] == 'form-1'
|
||||
assert resumed['submission']['values'] == {'priority': 'urgent'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_out_of_range_reference(store, event, binding):
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding)
|
||||
|
||||
with pytest.raises(ValueError, match='action reference is invalid'):
|
||||
await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'action_ref': 99},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
pending = await store.get_request('run-1', 'form-1')
|
||||
assert pending is not None
|
||||
assert pending['status'] == 'pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_negative_reference(store, event, binding):
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding)
|
||||
|
||||
with pytest.raises(ValueError, match='action reference is invalid'):
|
||||
await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'action_ref': -1},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_action_not_present_in_request(store, event, binding):
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding)
|
||||
|
||||
with pytest.raises(ValueError, match='action is not present'):
|
||||
await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'action_id': 'attacker-selected'},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_option_not_present_in_request(store, event, binding):
|
||||
result = _result()
|
||||
result['data']['payload']['fields'] = [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [{'value': 'normal', 'label': 'Normal'}],
|
||||
}
|
||||
]
|
||||
manager, callback_token = await _deliver_interaction(store, event, binding, result)
|
||||
|
||||
with pytest.raises(ValueError, match='option is not present'):
|
||||
await manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission={'values': {'priority': 'attacker-selected'}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for Host-owned structured interaction persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.agent.runner.interaction_store import (
|
||||
DuplicateInteractionError,
|
||||
InteractionAlreadySubmittedError,
|
||||
InteractionExpiredError,
|
||||
InteractionScopeError,
|
||||
InteractionStore,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(db_engine):
|
||||
return InteractionStore(db_engine)
|
||||
|
||||
|
||||
async def _create(store: InteractionStore, **overrides):
|
||||
values = {
|
||||
'interaction_id': 'form-1',
|
||||
'run_id': 'run-1',
|
||||
'binding_id': 'binding-1',
|
||||
'runner_id': 'plugin:test/ApprovalRunner/default',
|
||||
'processor_type': 'pipeline',
|
||||
'processor_id': 'pipeline-1',
|
||||
'request': {'interaction_id': 'form-1', 'title': 'Approve?', 'fallback_text': 'Reply yes or no.'},
|
||||
'delivery_target': {'chat_id': 'chat-1'},
|
||||
'bot_id': 'bot-1',
|
||||
'conversation_id': 'group_chat-1',
|
||||
'actor_id': 'user-1',
|
||||
'expires_at': int((datetime.datetime.now(UTC) + datetime.timedelta(minutes=5)).timestamp()),
|
||||
}
|
||||
values.update(overrides)
|
||||
return await store.create_request(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_request_returns_token_but_persists_only_hash(store, db_engine):
|
||||
record, token = await _create(store)
|
||||
|
||||
assert record['status'] == 'pending'
|
||||
assert record['processor_type'] == 'pipeline'
|
||||
assert token
|
||||
|
||||
async with db_engine.connect() as conn:
|
||||
result = await conn.execute(sqlalchemy.select(AgentInteraction.callback_token_hash))
|
||||
stored_hash = result.scalar_one()
|
||||
|
||||
assert stored_hash == hashlib.sha256(token.encode()).hexdigest()
|
||||
assert stored_hash != token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submission_is_scope_checked_and_consumed_once(store):
|
||||
_, token = await _create(store)
|
||||
|
||||
submitted = await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {'name': 'Alice'}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
assert submitted['status'] == 'submitted'
|
||||
assert submitted['submission']['action_id'] == 'approve'
|
||||
assert submitted['submission']['values'] == {'name': 'Alice'}
|
||||
|
||||
with pytest.raises(InteractionAlreadySubmittedError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_result_can_be_resolved_for_scoped_update(store):
|
||||
_, token = await _create(store)
|
||||
assert await store.record_delivery_success(
|
||||
'run-1',
|
||||
'form-1',
|
||||
{'message_id': 'message-1', 'rich': True},
|
||||
)
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
assert await store.record_delivery_success(
|
||||
'run-1',
|
||||
'form-1',
|
||||
{'message_id': 'message-1', 'card_id': 'card-1', 'sequence': 1, 'rich': True},
|
||||
)
|
||||
|
||||
target = await store.find_update_target(
|
||||
interaction_id='form-1',
|
||||
binding_id='binding-1',
|
||||
runner_id='plugin:test/ApprovalRunner/default',
|
||||
processor_type='pipeline',
|
||||
processor_id='pipeline-1',
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
assert target['delivery_result'] == {
|
||||
'message_id': 'message-1',
|
||||
'card_id': 'card-1',
|
||||
'sequence': 1,
|
||||
'rich': True,
|
||||
}
|
||||
|
||||
assert (
|
||||
await store.find_update_target(
|
||||
interaction_id='form-1',
|
||||
binding_id='binding-1',
|
||||
runner_id='plugin:test/ApprovalRunner/default',
|
||||
processor_type='pipeline',
|
||||
processor_id='pipeline-1',
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='other-user',
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_mismatch_does_not_consume_request(store):
|
||||
_, token = await _create(store)
|
||||
|
||||
with pytest.raises(InteractionScopeError, match='actor'):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='other-user',
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_request_is_marked_terminal(store):
|
||||
_, token = await _create(store)
|
||||
cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10)
|
||||
assert await store.expire_pending(now=cutoff) == 1
|
||||
|
||||
with pytest.raises(InteractionExpiredError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'expired'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forged_old_submission_time_cannot_bypass_server_expiry(store, db_engine):
|
||||
_, token = await _create(store)
|
||||
expired_at = datetime.datetime.now(UTC) - datetime.timedelta(minutes=1)
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sqlalchemy.update(AgentInteraction).where(AgentInteraction.run_id == 'run-1').values(expires_at=expired_at)
|
||||
)
|
||||
|
||||
with pytest.raises(InteractionExpiredError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
submitted_at=int((expired_at - datetime.timedelta(minutes=1)).timestamp()),
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'expired'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_id_is_unique_within_run_only(store):
|
||||
await _create(store)
|
||||
|
||||
with pytest.raises(DuplicateInteractionError):
|
||||
await _create(store)
|
||||
|
||||
second, _ = await _create(store, run_id='run-2')
|
||||
assert second['interaction_id'] == 'form-1'
|
||||
assert second['run_id'] == 'run-2'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expire_pending_updates_only_elapsed_rows(store):
|
||||
now = datetime.datetime.now(UTC)
|
||||
await _create(
|
||||
store,
|
||||
interaction_id='expired',
|
||||
run_id='run-expired',
|
||||
expires_at=int((now + datetime.timedelta(minutes=5)).timestamp()),
|
||||
)
|
||||
await _create(
|
||||
store,
|
||||
interaction_id='future',
|
||||
run_id='run-future',
|
||||
expires_at=int((now + datetime.timedelta(minutes=20)).timestamp()),
|
||||
)
|
||||
cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10)
|
||||
|
||||
expired = await store.expire_pending(now=cutoff)
|
||||
|
||||
assert expired == 1
|
||||
assert (await store.get_request('run-expired', 'expired'))['status'] == 'expired'
|
||||
assert (await store.get_request('run-future', 'future'))['status'] == 'pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_failure_is_terminal(store):
|
||||
await _create(store)
|
||||
|
||||
assert await store.mark_delivery_failed('run-1', 'form-1', 'adapter rejected card')
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'delivery_failed'
|
||||
assert record['status_reason'] == 'adapter rejected card'
|
||||
@@ -17,6 +17,7 @@ from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
|
||||
from langbot.pkg.agent.runner.interaction_store import InteractionStore
|
||||
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
|
||||
@@ -412,6 +413,67 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
|
||||
assert await get_session_registry().get(context['run_id']) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_consumes_interaction_request_before_message_output(clean_agent_state):
|
||||
"""Whitelisted interaction actions are delivered and never emitted as messages."""
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
# The local SDK pin predates this contract; these become typed fields with SDK 0.5.0a3.
|
||||
descriptor.capabilities.__dict__['interactions'] = True
|
||||
descriptor.permissions.__dict__['interactions'] = ['request']
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': 'action.requested',
|
||||
'data': {
|
||||
'action': 'interaction.requested',
|
||||
'payload': {
|
||||
'interaction_id': 'approval-1',
|
||||
'kind': 'choice',
|
||||
'title': 'Approve request?',
|
||||
'actions': [
|
||||
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
|
||||
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
|
||||
],
|
||||
'fallback_text': 'Reply approve or reject.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'Waiting for approval'}},
|
||||
},
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.adapter = types.SimpleNamespace(
|
||||
get_supported_apis=lambda: ['interaction.request'],
|
||||
call_platform_api=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert [message.content for message in messages] == ['Waiting for approval']
|
||||
query.adapter.call_platform_api.assert_awaited_once()
|
||||
action, params = query.adapter.call_platform_api.await_args.args
|
||||
assert action == 'interaction.request'
|
||||
assert params['reply_target'] == {
|
||||
'target_type': 'person',
|
||||
'target_id': 'user_001',
|
||||
'message_id': 'msg_001',
|
||||
}
|
||||
assert params['callback_token']
|
||||
|
||||
run_id = plugin_connector.contexts[0]['run_id']
|
||||
request = await InteractionStore(db_engine).get_request(run_id, 'approval-1')
|
||||
assert request is not None
|
||||
assert request['status'] == 'pending'
|
||||
assert request['processor_type'] == 'pipeline'
|
||||
assert request['processor_id'] == 'pipeline_001'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_persists_run_ledger(clean_agent_state):
|
||||
"""AgentRunOrchestrator records Host-owned run and result events."""
|
||||
|
||||
Reference in New Issue
Block a user