"""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', )