feat(agent): integrate structured runner interactions

This commit is contained in:
fdc310
2026-07-22 11:12:26 +08:00
parent e3df35e7f7
commit 6b13c37c3b
58 changed files with 6153 additions and 319 deletions
+160 -105
View File
@@ -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."""
@@ -366,6 +366,36 @@ class TestMessageAggregatorAddMessage:
# Should have buffered the message
assert len(agg.buffers) == 1
@pytest.mark.asyncio
async def test_control_variables_bypass_aggregation(self):
"""Interaction callbacks must not merge with nearby chat messages."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {'trigger': {'message-aggregation': {'enabled': True, 'delay': 10.0}}}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
chain = text_chain('approve')
await agg.add_message(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=friend_message_event(chain),
message_chain=chain,
adapter=mock_adapter(),
pipeline_uuid='test-pipeline',
variables={'_interaction_submission': {'interaction_id': 'form-1'}},
)
assert agg.buffers == {}
app.query_pool.add_query.assert_awaited_once()
assert app.query_pool.add_query.call_args.kwargs['variables'] == {
'_interaction_submission': {'interaction_id': 'form-1'}
}
@pytest.mark.asyncio
async def test_max_buffer_flushes_immediately(self):
"""Reaching MAX_BUFFER_MESSAGES should flush immediately."""
@@ -164,6 +164,96 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
mock_stage.process.assert_called_once()
@pytest.mark.asyncio
async def test_runtime_pipeline_delivers_latest_chunk_as_final(mock_app, sample_query):
"""The terminal chunk, not the first chunk, controls final stream delivery."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
entities = get_entities_module()
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = sample_query.pipeline_config
pipeline_entity.extensions_preferences = {'plugins': []}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
sample_query.resp_messages = [first_chunk, final_chunk]
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
result = entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
new_query=sample_query,
user_notice='StartingDone',
)
await runtime_pipeline._check_output(sample_query, result)
sample_query.adapter.reply_message_chunk.assert_awaited_once()
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
assert call['bot_message'] is final_chunk
assert call['is_final'] is True
@pytest.mark.asyncio
async def test_response_back_stage_delivers_latest_chunk_as_final(mock_app, sample_query):
respback = import_module('langbot.pkg.pipeline.respback.respback')
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
sample_query.resp_messages = [first_chunk, final_chunk]
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='StartingDone')])]
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
sample_query.adapter.reply_message_chunk.assert_awaited_once()
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
assert call['bot_message'] is final_chunk
assert call['is_final'] is True
@pytest.mark.asyncio
async def test_response_back_stage_keeps_consuming_after_stream_delivery_failure(mock_app, sample_query):
respback = import_module('langbot.pkg.pipeline.respback.respback')
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
chunk = provider_message.MessageChunk(role='assistant', content='Progress', is_final=False)
sample_query.resp_messages = [chunk]
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Progress')])]
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
assert result.result_type.name == 'CONTINUE'
sample_query.adapter.reply_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_response_back_stage_falls_back_to_plain_message_for_failed_final_chunk(mock_app, sample_query):
respback = import_module('langbot.pkg.pipeline.respback.respback')
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
chunk = provider_message.MessageChunk(role='assistant', content='Final answer', is_final=True)
sample_query.resp_messages = [chunk]
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Final answer')])]
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
assert result.result_type.name == 'CONTINUE'
sample_query.adapter.reply_message.assert_awaited_once()
def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
"""Runner resource selection should override extension preferences."""
pipelinemgr = get_pipelinemgr_module()
+23
View File
@@ -199,6 +199,29 @@ class TestQueryPoolAddQuery:
call_kwargs = MockQuery.call_args[1]
assert call_kwargs['variables']['_routed_by_rule'] is True
async def test_add_query_merges_control_variables(self):
"""Caller-provided control variables are preserved with routing metadata."""
pool = QueryPool()
mock_query = Mock(query_id=0)
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
routed_by_rule=True,
variables={'_interaction_submission': {'interaction_id': 'form-1'}},
)
variables = MockQuery.call_args.kwargs['variables']
assert variables['_routed_by_rule'] is True
assert variables['_interaction_submission'] == {'interaction_id': 'form-1'}
async def test_add_query_notifier_condition(self):
"""add_query notifies waiting consumers."""
pool = QueryPool()
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import pathlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -13,6 +14,7 @@ from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.dingtalk.interaction import interaction_event_from_native
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -49,6 +51,7 @@ class DummyDingTalkClient(DingTalkClient):
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
self.send_card_message = AsyncMock()
self.create_and_deliver_card = AsyncMock(return_value=True)
self.start = AsyncMock()
self.stop = AsyncMock()
@@ -71,7 +74,7 @@ def manifest() -> dict:
/ 'dingtalk'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
def make_adapter() -> DingTalkAdapter:
@@ -84,6 +87,7 @@ def make_adapter() -> DingTalkAdapter:
'enable-stream-reply': False,
'card_auto_layout': False,
'card_template_id': 'template-id',
'human_input_card_template_id': 'human-input-template-id',
}
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
return DingTalkAdapter(config, DummyLogger())
@@ -157,6 +161,36 @@ def test_dingtalk_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions
def test_dingtalk_human_input_card_template_matches_interaction_contract():
template_path = (
pathlib.Path(__file__).parents[3] / 'src' / 'langbot' / 'templates' / 'dingtalk_human_input_card.json'
)
exported_template = json.loads(template_path.read_text(encoding='utf-8'))
editor_data = json.loads(exported_template['editorData'])
variable_ids = {item['id'] for item in editor_data['variableList']}
serialized_editor = json.dumps(editor_data, ensure_ascii=False)
assert exported_template['type'] == 'im'
assert exported_template['mode'] == 'card'
assert {
'content',
'btns',
'input_visible',
'input_title',
'input_placeholder',
'input_value',
'select_visible',
'select_placeholder',
'select_options',
'select_index',
'index_o',
} <= variable_ids
assert '"componentName": "Input"' in serialized_editor
assert '"componentName": "SelectBlock"' in serialized_editor
assert '__built_in_inputResult__' in serialized_editor
assert '__built_in_selectResult__' in serialized_editor
@pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_outbound_components():
content, at = await DingTalkMessageConverter.yiri2target(
@@ -313,3 +347,178 @@ async def test_dingtalk_send_reply_and_platform_api_use_underlying_client():
assert token_status == {'valid': True}
assert file_url == {'url': 'https://example.test/file'}
@pytest.mark.asyncio
async def test_dingtalk_interaction_delivery_and_callback_event():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result['rich'] is True
kwargs = adapter.bot.create_and_deliver_card.await_args.kwargs
assert kwargs['card_template_id'] == 'human-input-template-id'
assert kwargs['open_space_id'] == 'dtv1.card//IM_GROUP.group-1'
assert kwargs['card_param_map']['btns'][0]['event']['params']['actionId'] == 'lbi:callback-token:a:0'
native = dingtalk_card_callback_event(
content={'actionId': 'lbi:callback-token:a:0'},
space_id='dtv1.card//IM_GROUP.group-1',
)
event = interaction_event_from_native(native)
assert event.action == 'interaction.submitted'
assert event.data['action_ref'] == 0
assert event.data['actor_id'] == 'user-1'
assert event.data['target_id'] == 'group-1'
@pytest.mark.asyncio
async def test_dingtalk_native_input_uses_template_variables_and_submits_value():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Add context',
'fields': [
{
'id': 'comment',
'label': 'Comment',
'type': 'textarea',
'required': True,
'placeholder': 'Explain the decision',
}
],
'actions': [],
'fallback_text': 'Reply with a comment.',
},
},
)
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
assert params['input_visible'] == 'true'
assert params['input_title'] == 'Comment'
assert params['input_placeholder'] == 'Explain the decision'
assert params['select_visible'] == ''
assert params['btns'] == []
assert result['message_id'] in adapter.interaction_callback_contexts
native = dingtalk_card_callback_event(
content={
'outTrackId': result['message_id'],
'params': {'input': 'Looks good'},
},
space_id='dtv1.card//IM_GROUP.group-1',
)
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
assert event.data['callback_token'] == 'callback-token'
assert event.data['values'] == {'comment': 'Looks good'}
assert result['message_id'] not in adapter.interaction_callback_contexts
@pytest.mark.asyncio
async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Priority',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'options': [
{'value': 'normal', 'label': 'Normal'},
{'value': 'urgent', 'label': 'Urgent'},
],
}
],
'actions': [],
'fallback_text': 'Choose a priority.',
},
},
)
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
assert params['select_visible'] == 'true'
assert [option['value'] for option in params['index_o']] == ['normal', 'urgent']
assert params['input_visible'] == ''
native = dingtalk_card_callback_event(
content={
'out_track_id': result['message_id'],
'params': {'select': '{"index": 1, "value": "urgent"}'},
},
space_id='dtv1.card//IM_ROBOT.user-1',
)
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
assert event.data['target_type'] == 'person'
assert event.data['target_id'] == 'user-1'
assert event.data['values'] == {'priority': 'urgent'}
@pytest.mark.asyncio
async def test_dingtalk_adapter_dispatches_native_input_submission():
adapter = make_adapter()
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Score',
'fields': [
{
'id': 'score',
'label': 'Score',
'type': 'number',
}
],
'actions': [],
'fallback_text': 'Reply with a score.',
},
},
)
native = dingtalk_card_callback_event(
content={
'outTrackId': result['message_id'],
'params': {'inputResult': '42.5'},
},
space_id='dtv1.card//IM_GROUP.group-1',
)
await adapter._handle_native_event(native)
assert len(calls) == 1
assert calls[0].action == 'interaction.submitted'
assert calls[0].data['callback_token'] == 'callback-token'
assert calls[0].data['values'] == {'score': 42.5}
assert result['message_id'] not in adapter.interaction_callback_contexts
@@ -13,6 +13,11 @@ import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.discord.interaction import (
build_interaction_view,
interaction_event_from_component,
parse_interaction_custom_id,
)
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -48,7 +53,7 @@ def manifest() -> dict:
/ 'discord'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
def test_discord_supported_events_match_manifest():
@@ -68,6 +73,64 @@ def test_discord_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions
def test_discord_interaction_custom_id_parser_uses_host_indexes():
assert parse_interaction_custom_id('lbi:token:a:2') == {
'callback_token': 'token',
'action_ref': 2,
}
assert parse_interaction_custom_id('lbi:token:f:0:3') == {
'callback_token': 'token',
'field_ref': 0,
'option_ref': 3,
}
@pytest.mark.asyncio
async def test_discord_interaction_view_and_delivery():
adapter = make_adapter()
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=321)))
adapter._get_channel = AsyncMock(return_value=channel)
request = {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
'fallback_text': 'Reply approve.',
}
view = build_interaction_view(request, 'callback-token')
assert view.children[0].custom_id == 'lbi:callback-token:a:0'
result = await adapter.call_platform_api(
'interaction.request',
{
'request': request,
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': '789'},
},
)
assert result == {'ok': True, 'message_id': 321, 'rich': True}
assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve'
def test_discord_interaction_component_builds_scoped_host_event():
interaction = SimpleNamespace(
user=SimpleNamespace(id=123),
channel=SimpleNamespace(id=789),
guild=SimpleNamespace(id=456),
)
event = interaction_event_from_component(
interaction,
{'callback_token': 'callback-token', 'action_ref': 0},
)
assert event.action == 'interaction.submitted'
assert event.data['actor_id'] == '123'
assert event.data['target_type'] == 'group'
assert event.data['target_id'] == '789'
@pytest.mark.asyncio
async def test_discord_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
import pathlib
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
@@ -12,6 +14,11 @@ from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.lark.interaction import (
build_interaction_card,
interaction_delivery_capabilities,
interaction_event_from_callback,
)
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -66,6 +73,7 @@ class DummyAPIClient:
message=SimpleNamespace(
acreate=AsyncMock(return_value=DummyResponse()),
areply=AsyncMock(return_value=DummyResponse()),
aupdate=AsyncMock(return_value=DummyResponse()),
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))),
),
chat=SimpleNamespace(
@@ -89,8 +97,12 @@ class DummyAPIClient:
)
self.cardkit = SimpleNamespace(
v1=SimpleNamespace(
card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))),
card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())),
card=SimpleNamespace(
create=MagicMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
aupdate=AsyncMock(return_value=DummyResponse()),
),
card_element=SimpleNamespace(content=MagicMock(return_value=DummyResponse())),
)
)
@@ -114,7 +126,7 @@ def manifest() -> dict:
/ 'lark'
/ 'manifest.yaml'
)
return yaml.safe_load(path.read_text())
return yaml.safe_load(path.read_text(encoding='utf-8'))
def make_adapter(config: dict | None = None) -> LarkAdapter:
@@ -324,3 +336,550 @@ async def test_lark_send_reply_platform_api_and_modes():
with pytest.raises(asyncio.CancelledError):
await task
webhook_adapter.bot._connect.assert_not_awaited()
def test_lark_interaction_card_uses_host_callback_token_and_indexes():
card = build_interaction_card(
{
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
},
'callback-token',
{'target_type': 'group', 'target_id': 'chat-1'},
)
assert card['schema'] == '2.0'
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
assert button['behaviors'][0]['value'] == {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}
def test_lark_interaction_card_uses_native_text_input_form():
card = build_interaction_card(
{
'title': 'Add context',
'fields': [
{
'id': 'comment',
'label': 'Comment',
'type': 'textarea',
'required': True,
'placeholder': 'Explain the decision',
}
],
'actions': [],
},
'callback-token',
{'target_type': 'group', 'target_id': 'chat-1'},
)
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
field, submit = form['elements']
assert field['tag'] == 'input'
assert field['input_type'] == 'multiline_text'
assert field['required'] is True
assert submit['form_action_type'] == 'submit'
assert submit['behaviors'][0]['value'] == {
'lbi': 'callback-token',
't': 'group',
'ck': 1,
'fm': {'lbi_field_0': 'comment'},
'ft': {'comment': 'textarea'},
}
def test_lark_interaction_card_uses_native_select_and_declares_fields():
card = build_interaction_card(
{
'title': 'Priority',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'required': True,
'options': [
{'value': 'normal', 'label': 'Normal'},
{'value': 'urgent', 'label': 'Urgent'},
],
}
],
'actions': [],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
)
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
label, select, submit = form['elements']
assert label == {'tag': 'markdown', 'content': '**Priority***'}
assert select['tag'] == 'select_static'
assert [option['value'] for option in select['options']] == ['normal', 'urgent']
assert submit['form_action_type'] == 'submit'
assert interaction_delivery_capabilities()['field_types'] == ['text', 'textarea', 'number', 'select']
assert interaction_delivery_capabilities()['supports_updates'] is True
@pytest.mark.asyncio
async def test_lark_interaction_request_sends_interactive_message():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result['rich'] is True
assert result['card_id'] == 'card-id'
adapter.api_client.cardkit.v1.card.acreate.assert_awaited_once()
request = adapter.api_client.im.v1.message.acreate.await_args.args[0]
assert request.receive_id_type == 'open_id'
assert request.request_body.msg_type == 'interactive'
assert json.loads(request.request_body.content) == {'type': 'card', 'data': {'card_id': 'card-id'}}
@pytest.mark.asyncio
async def test_lark_interaction_request_updates_existing_message():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'next-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'update_target': {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 0,
'rich': True,
},
'request': {
'interaction_id': 'form-2',
'title': 'Choose action',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result == {
'ok': True,
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 1,
'rich': True,
'updated': True,
}
adapter.api_client.im.v1.message.acreate.assert_not_awaited()
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
assert update_request.card_id == 'card-id'
assert update_request.request_body.sequence == 1
card = json.loads(update_request.request_body.card.data)
assert card['header']['title']['content'] == 'Choose action'
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
assert button['behaviors'][0]['value']['lbi'] == 'next-token'
@pytest.mark.asyncio
async def test_lark_interaction_acknowledge_replaces_controls_with_summary():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.acknowledge',
{
'update_target': {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 0,
'rich': True,
},
'request': {
'title': 'Manual review',
'fields': [{'id': 'comment', 'label': 'Comment'}],
},
'submission': {'values': {'comment': 'Looks good'}},
},
)
assert result['updated'] is True
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
card = json.loads(update_request.request_body.card.data)
content = card['body']['elements'][-1]['content']
assert content == '✅ CommentLooks good'
@pytest.mark.asyncio
async def test_lark_interaction_keeps_prior_values_through_next_action_and_final_acknowledgement():
adapter = make_adapter()
update_target = {
'message_id': 'card-message-1',
'card_id': 'card-id',
'sequence': 1,
'rich': True,
'submitted_values': [{'label': 'Question', 'value': '123'}],
}
action_result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'next-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'update_target': update_target,
'request': {
'interaction_id': 'action-1',
'title': 'Manual review',
'actions': [{'id': 'or', 'label': 'or'}],
'fallback_text': 'Reply or.',
},
},
)
action_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
action_card = json.loads(action_update.request_body.card.data)
assert '✅ Question123' in action_card['body']['elements'][0]['content']
assert action_result['submitted_values'] == [{'label': 'Question', 'value': '123'}]
final_result = await adapter.call_platform_api(
'interaction.acknowledge',
{
'update_target': action_result,
'request': {
'title': 'Manual review',
'actions': [{'id': 'or', 'label': 'or'}],
},
'submission': {'action_id': 'or', 'values': {}},
},
)
final_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
final_card = json.loads(final_update.request_body.card.data)
final_content = final_card['body']['elements'][-1]['content']
assert '✅ Actionor' in final_content
assert '✅ Question123' in final_card['body']['elements'][0]['content']
assert final_result['submitted_values'] == [
{'label': 'Question', 'value': '123'},
{'label': 'Action', 'value': 'or'},
]
def test_lark_dify_layout_keeps_prompts_next_to_submitted_values():
prior_values = [
{
'description': '11\n请输入你的问题',
'label': 'us_input',
'value': '回复我你好',
}
]
select_card = build_interaction_card(
{
'title': '人工介入',
'description': '请选择你的答案',
'fields': [
{
'id': 'field_2',
'label': 'xiala',
'type': 'select',
'options': [
{'value': '1', 'label': '1'},
{'value': '2', 'label': '2'},
],
}
],
'actions': [],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
prior_values,
)
select_elements = select_card['body']['elements']
assert select_elements[0]['content'] == '11\n请输入你的问题\n✅ us_input:回复我你好'
assert select_elements[1]['content'] == '请选择你的答案'
assert select_elements[2]['tag'] == 'form'
action_card = build_interaction_card(
{
'title': '人工介入',
'description': None,
'fields': [],
'actions': [{'id': 'action_1', 'label': 'or'}],
},
'callback-token',
{'target_type': 'person', 'target_id': 'user-1'},
[
*prior_values,
{
'description': '请选择你的答案',
'label': 'xiala',
'value': '1',
},
],
)
action_elements = action_card['body']['elements']
assert [element['content'] for element in action_elements[:2]] == [
'11\n请输入你的问题\n✅ us_input:回复我你好',
'请选择你的答案\n✅ xiala1',
]
assert action_elements[2]['columns'][0]['elements'][0]['text']['content'] == 'or'
def test_lark_message_id_from_card_callback_source():
adapter = make_adapter()
callback_source = SimpleNamespace(
event=SimpleNamespace(context=SimpleNamespace(open_message_id='card-message-1')),
)
assert (
adapter._message_id_from_source(SimpleNamespace(message_id=None, source_platform_object=callback_source))
== 'card-message-1'
)
assert (
adapter._message_id_from_source(
SimpleNamespace(
message_id=None,
source_platform_object={'event': {'context': {'open_message_id': 'webhook-message-1'}}},
)
)
== 'webhook-message-1'
)
@pytest.mark.asyncio
async def test_lark_streaming_card_uses_strictly_increasing_sequences():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
await adapter.reply_message_chunk(source, bot_message, message)
first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert first_request.request_body.sequence == 1
bot_message.msg_sequence = 2
await adapter.reply_message_chunk(source, bot_message, message)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
bot_message.msg_sequence = 8
await adapter.reply_message_chunk(source, bot_message, message)
second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert second_request.request_body.sequence == 2
bot_message.msg_sequence = 9
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert final_request.request_body.sequence == 3
assert 'response-1' not in adapter.card_id_dict
assert 'stream-card-1' not in adapter.card_sequence_dict
assert 'stream-card-1' not in adapter.card_last_update_dict
@pytest.mark.asyncio
async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eighth():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 1
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'new progress'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='new progress')])
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.sequence == 2
@pytest.mark.asyncio
async def test_lark_streaming_card_uses_cumulative_runner_content():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(
return_value=([[{'tag': 'text', 'text': 'latest chunk only'}]], [])
)
bot_message = SimpleNamespace(
resp_message_id='response-1',
msg_sequence=2,
all_content='first chunk\n\nlatest chunk only',
tool_calls=None,
)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='latest chunk only')])
await adapter.reply_message_chunk(source, bot_message, message)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.content == 'first chunk\n\nlatest chunk only'
adapter.message_converter.yiri2target.assert_not_awaited()
@pytest.mark.asyncio
async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 0
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
assert request.request_body.sequence == 1
@pytest.mark.asyncio
async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_closes():
adapter = make_adapter()
adapter.card_id_dict['response-1'] = 'stream-card-1'
adapter.card_sequence_dict['stream-card-1'] = 1
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
closed_response = DummyResponse(ok=False)
closed_response.code = 300309
closed_response.msg = 'streaming mode is closed'
adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response
adapter.message_converter.yiri2target = AsyncMock(
return_value=([[{'tag': 'text', 'text': 'continued progress'}]], [])
)
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
source = SimpleNamespace(source_platform_object=None)
message = platform_message.MessageChain([platform_message.Plain(text='continued progress')])
await adapter.reply_message_chunk(source, bot_message, message)
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
updated_card = json.loads(update_request.request_body.card.data)
assert update_request.request_body.sequence == 3
assert updated_card['config'] == {'update_multi': True}
assert updated_card['body']['elements'][0]['content'] == 'continued progress'
assert 'stream-card-1' in adapter.closed_streaming_cards
bot_message.msg_sequence = 3
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2
assert 'stream-card-1' not in adapter.closed_streaming_cards
def test_lark_interaction_callback_builds_scoped_host_event():
callback = SimpleNamespace(
event=SimpleNamespace(
action=SimpleNamespace(value={'lbi': 'callback-token', 't': 'group', 'a': 1}),
operator=SimpleNamespace(open_id='user-1'),
context=SimpleNamespace(open_chat_id='chat-1', open_message_id='card-message-1'),
)
)
event = interaction_event_from_callback(callback)
assert event.action == 'interaction.submitted'
assert event.data['callback_token'] == 'callback-token'
assert event.data['action_ref'] == 1
assert event.data['actor_id'] == 'user-1'
assert event.data['target_id'] == 'chat-1'
assert event.data['message_id'] == 'card-message-1'
def test_lark_native_form_callback_submits_typed_field_value():
callback = SimpleNamespace(
event=SimpleNamespace(
action=SimpleNamespace(
value={
'lbi': 'callback-token',
't': 'person',
'fm': {'lbi_field_0': 'score'},
'ft': {'score': 'number'},
},
form_value={'lbi_field_0': '42.5'},
),
operator=SimpleNamespace(open_id='user-1'),
context=SimpleNamespace(open_chat_id='chat-1'),
)
)
event = interaction_event_from_callback(callback)
assert event.data['callback_token'] == 'callback-token'
assert event.data['target_type'] == 'person'
assert event.data['target_id'] == 'user-1'
assert event.data['values'] == {'score': 42.5}
@pytest.mark.asyncio
async def test_lark_webhook_dispatches_native_form_submission():
adapter = make_adapter({'enable-webhook': True})
calls: list[platform_events.Event] = []
async def listener(event, adapter):
calls.append(event)
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
payload = {
'schema': '2.0',
'header': {'event_type': 'card.action.trigger'},
'event': {
'action': {
'value': {
'lbi': 'callback-token',
't': 'group',
'fm': {'lbi_field_0': 'comment'},
'ft': {'comment': 'textarea'},
},
'form_value': {'lbi_field_0': 'Looks good'},
},
'operator': {'open_id': 'user-1'},
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
},
}
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'}
assert response['card']['type'] == 'raw'
assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**'
assert len(calls) == 1
assert calls[0].action == 'interaction.submitted'
assert calls[0].data['callback_token'] == 'callback-token'
assert calls[0].data['values'] == {'comment': 'Looks good'}
assert calls[0].data['message_id'] == 'card-message-1'
@pytest.mark.asyncio
async def test_lark_webhook_cardkit_submission_uses_async_card_update_only():
adapter = make_adapter({'enable-webhook': True})
async def listener(event, adapter):
pass
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
payload = {
'schema': '2.0',
'header': {'event_type': 'card.action.trigger'},
'event': {
'action': {'value': {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}},
'operator': {'open_id': 'user-1'},
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
},
}
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}}
@@ -13,6 +13,10 @@ from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.qqofficial.interaction import (
build_interaction_keyboard,
interaction_event_from_payload,
)
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -49,6 +53,9 @@ class DummyQQOfficialClient:
self.access_token_expiry_time = None
self.handle_unified_webhook = AsyncMock(return_value='success')
self.connect_gateway_loop = AsyncMock()
self.send_markdown_keyboard = AsyncMock(return_value={'id': 'keyboard-message'})
self.ack_interaction = AsyncMock()
self._interaction_handler = None
def on_message(self, msg_type: str):
def decorator(func):
@@ -57,6 +64,13 @@ class DummyQQOfficialClient:
return decorator
def on_interaction(self):
def decorator(func):
self._interaction_handler = func
return func
return decorator
async def check_access_token(self):
return bool(self.access_token)
@@ -111,7 +125,7 @@ def manifest() -> dict:
/ 'qqofficial'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter:
@@ -164,6 +178,54 @@ def test_qqofficial_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions
def test_qqofficial_interaction_keyboard_contains_only_host_token_and_index():
keyboard = build_interaction_keyboard(
{'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}]},
'callback-token',
)
button = keyboard['content']['rows'][0]['buttons'][0]
assert button['action']['data'] == 'lbi:callback-token:a:0'
@pytest.mark.asyncio
async def test_qqofficial_interaction_delivery_and_callback_event():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {
'target_type': 'group',
'target_id': 'group-openid',
'message_id': 'msg-1',
},
'request': {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True}
assert adapter.bot.send_markdown_keyboard.await_args.kwargs['msg_id'] == 'msg-1'
event = interaction_event_from_payload(
{
'id': 'interaction-id',
'chat_type': 1,
'group_openid': 'group-openid',
'member_openid': 'user-openid',
'data': {'resolved': {'button_data': 'lbi:callback-token:a:0'}},
}
)
assert event.action == 'interaction.submitted'
assert event.data['action_ref'] == 0
assert event.data['actor_id'] == 'user-openid'
@pytest.mark.asyncio
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
payload = await QQOfficialMessageConverter.yiri2target(
+220 -2
View File
@@ -73,7 +73,7 @@ class TestEventRouteTrace:
captured_envelopes = []
async def fake_run(envelope, binding):
async def fake_run(envelope, binding, adapter_context=None):
captured_envelopes.append(envelope)
yield provider_message.Message(role='assistant', content='test response')
@@ -157,7 +157,7 @@ class TestEventRouteTrace:
}
runner_calls = []
async def fake_run(envelope, binding):
async def fake_run(envelope, binding, adapter_context=None):
runner_calls.append((envelope, binding))
if False:
yield None
@@ -378,6 +378,53 @@ class TestRuntimeBotLifecycle:
bot.adapter.kill.assert_awaited_once()
task_mgr.cancel_task.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_uses_only_eba_message_listener_for_eba_adapter(self):
"""An EBA-native message must not also enter through the legacy compatibility listener."""
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.entities.builtin.platform import events as platform_events
listeners = {}
adapter = SimpleNamespace(
get_supported_events=Mock(return_value=['message.received']),
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
)
bot = RuntimeBot(
ap=SimpleNamespace(),
bot_entity=SimpleNamespace(enable=True),
adapter=adapter,
logger=Mock(),
)
await bot.initialize()
assert platform_events.EBAEvent in listeners
assert platform_events.FriendMessage not in listeners
assert platform_events.GroupMessage not in listeners
@pytest.mark.asyncio
async def test_initialize_keeps_legacy_message_listeners_for_legacy_adapter(self):
"""Adapters without EBA message support retain the compatibility entry path."""
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.entities.builtin.platform import events as platform_events
listeners = {}
adapter = SimpleNamespace(
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
)
bot = RuntimeBot(
ap=SimpleNamespace(),
bot_entity=SimpleNamespace(enable=True),
adapter=adapter,
logger=Mock(),
)
await bot.initialize()
assert platform_events.FriendMessage in listeners
assert platform_events.GroupMessage in listeners
assert platform_events.EBAEvent in listeners
class TestEBAEventBindings:
"""Test RuntimeBot EBA event binding helpers."""
@@ -482,8 +529,11 @@ class TestEBAEventBindings:
assert binding.resource_policy.allowed_tool_names is None
assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True
assert binding.delivery_policy.enable_interactions is True
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
assert binding.agent_id == 'agent-1'
assert binding.processor_type == 'agent'
assert binding.processor_id == 'agent-1'
def test_agent_product_to_binding_projects_selected_tool_policy(self):
"""Independent Agents use the same standard runner resource fields as Pipelines."""
@@ -513,6 +563,174 @@ class TestEBAEventBindings:
assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool']
assert binding.resource_policy.allowed_kb_uuids == ['kb-1']
class TestInteractionResumeRouting:
"""Interaction callbacks resume the processor that created the request."""
@staticmethod
def _event():
from langbot_plugin.api.entities.builtin.platform.events import PlatformSpecificEvent
return PlatformSpecificEvent(
action='interaction.submitted',
timestamp=1234,
data={
'callback_token': 'callback-token',
'interaction_id': 'form-1',
'action_id': 'approve',
'values': {'name': 'Alice'},
'actor_id': 'user-1',
'target_type': 'group',
'target_id': 'chat-1',
'message_id': 'card-message-1',
},
)
@staticmethod
def _record(processor_type: str):
return {
'id': 1,
'interaction_id': 'form-1',
'binding_id': 'binding-1',
'runner_id': 'plugin:test/Dify/default',
'processor_type': processor_type,
'processor_id': f'{processor_type}-1',
'workspace_id': None,
'conversation_id': 'group_chat-1',
'thread_id': None,
'delivery_target': {'target_type': 'group', 'target_id': 'chat-1'},
'submission': {
'interaction_id': 'form-1',
'action_id': 'approve',
'values': {'name': 'Alice'},
'submitted_at': 1234,
},
}
@staticmethod
def _make_bot(record):
from langbot.pkg.platform.botmgr import RuntimeBot
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(uuid='bot-1', name='Test', event_bindings=[])
interaction_manager = SimpleNamespace(
consume_callback=AsyncMock(return_value=record),
acknowledge_submission=AsyncMock(),
)
bot.ap = SimpleNamespace(
agent_run_orchestrator=SimpleNamespace(interaction_manager=interaction_manager),
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
pipeline_service=SimpleNamespace(
get_pipeline=AsyncMock(
return_value={
'uuid': record['processor_id'],
'config': {
'ai': {
'runner': {'id': record['runner_id']},
'runner_config': {record['runner_id']: {}},
}
},
}
)
),
)
return bot
@pytest.mark.asyncio
async def test_pipeline_callback_bypasses_route_table_and_targets_original_pipeline(self):
bot = self._make_bot(self._record('pipeline'))
adapter = SimpleNamespace()
await bot._handle_interaction_submission(self._event(), adapter)
bot.ap.msg_aggregator.add_message.assert_awaited_once()
kwargs = bot.ap.msg_aggregator.add_message.await_args.kwargs
assert kwargs['pipeline_uuid'] == 'pipeline-1'
assert kwargs['routed_by_rule'] is True
assert kwargs['variables']['_interaction_submission']['interaction_id'] == 'form-1'
assert kwargs['message_chain'].message_id == 'card-message-1'
assert kwargs['message_event'].message_chain.message_id == 'card-message-1'
manager = bot.ap.agent_run_orchestrator.interaction_manager
manager.consume_callback.assert_awaited_once_with(
callback_token='callback-token',
submission={
'interaction_id': 'form-1',
'action_id': 'approve',
'values': {'name': 'Alice'},
'submitted_at': 1234,
},
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-1',
)
@pytest.mark.asyncio
async def test_callback_forwards_compact_platform_references(self):
bot = self._make_bot(self._record('pipeline'))
adapter = SimpleNamespace()
event = self._event()
event.data.pop('interaction_id')
event.data.pop('action_id')
event.data['action_ref'] = 1
await bot._handle_interaction_submission(event, adapter)
manager = bot.ap.agent_run_orchestrator.interaction_manager
submission = manager.consume_callback.await_args.kwargs['submission']
assert submission['action_ref'] == 1
assert submission['interaction_id'] is None
@pytest.mark.asyncio
async def test_pipeline_callback_rejects_changed_runner(self):
bot = self._make_bot(self._record('pipeline'))
bot.ap.pipeline_service.get_pipeline.return_value['config']['ai']['runner']['id'] = 'plugin:test/Other/default'
with pytest.raises(ValueError, match='Pipeline runner changed'):
await bot._handle_interaction_submission(self._event(), SimpleNamespace())
bot.ap.msg_aggregator.add_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_agent_callback_targets_original_agent_and_runner(self):
import langbot_plugin.api.entities.builtin.provider.message as provider_message
captured = []
async def fake_run(envelope, binding, adapter_context=None):
captured.append((envelope, binding, adapter_context))
yield provider_message.Message(role='assistant', content='approved')
bot = self._make_bot(self._record('agent'))
bot.ap.agent_service = SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': 'agent',
'enabled': True,
'config': {
'runner': {'id': 'plugin:test/Dify/default'},
'runner_config': {'plugin:test/Dify/default': {}},
},
}
)
)
bot.ap.agent_run_orchestrator.run = fake_run
adapter = SimpleNamespace(
get_supported_apis=Mock(return_value=['send_message']),
send_message=AsyncMock(),
)
await bot._handle_interaction_submission(self._event(), adapter)
envelope, binding, adapter_context = captured[0]
assert envelope.event_type == 'interaction.submitted'
assert envelope.data['interaction']['action_id'] == 'approve'
assert binding.binding_id == 'binding-1'
assert binding.processor_type == 'agent'
assert binding.processor_id == 'agent-1'
assert adapter_context == {'_delivery_adapter': adapter}
adapter.send_message.assert_awaited_once()
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
"""An empty config runner stays unconfigured even if component_ref is stale."""
from langbot.pkg.platform.botmgr import RuntimeBot
@@ -14,6 +14,10 @@ from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
from langbot.pkg.platform.adapters.telegram.interaction import (
interaction_event_from_update,
parse_interaction_callback,
)
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -152,13 +156,159 @@ def test_telegram_supported_events_match_manifest():
/ 'telegram'
/ 'manifest.yaml'
)
manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events']
manifest_events = yaml.safe_load(manifest_path.read_text(encoding='utf-8'))['spec']['supported_events']
assert adapter_events == manifest_events
assert 'message.deleted' not in adapter_events
assert 'group.info_updated' not in adapter_events
def test_telegram_interaction_callback_parser_uses_compact_indexes():
assert parse_interaction_callback('lbi:token:a:2') == {
'callback_token': 'token',
'action_ref': 2,
}
assert parse_interaction_callback('lbi:token:f:1:3') == {
'callback_token': 'token',
'field_ref': 1,
'option_ref': 3,
}
assert parse_interaction_callback('ordinary-button') is None
with pytest.raises(ValueError, match='invalid Telegram interaction callback'):
parse_interaction_callback('lbi:token:a:not-an-index')
@pytest.mark.asyncio
async def test_telegram_interaction_request_renders_action_buttons_and_thread_target():
adapter = make_adapter()
sent = SimpleNamespace(message_id=321)
bot = SimpleNamespace(send_message=AsyncMock(return_value=sent))
object.__setattr__(adapter, 'bot', bot)
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': '-1001#7'},
'request': {
'interaction_id': 'form-1',
'kind': 'choice',
'title': 'Approve?',
'actions': [
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
],
'fallback_text': 'Reply approve or reject.',
},
},
)
assert result == {'ok': True, 'message_id': 321, 'rich': True}
kwargs = bot.send_message.await_args.kwargs
assert kwargs['chat_id'] == -1001
assert kwargs['message_thread_id'] == 7
buttons = kwargs['reply_markup'].inline_keyboard
assert buttons[0][0].callback_data == 'lbi:callback-token:a:0'
assert buttons[1][0].callback_data == 'lbi:callback-token:a:1'
@pytest.mark.asyncio
async def test_telegram_interaction_request_renders_select_or_falls_back():
adapter = make_adapter()
bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=1)))
object.__setattr__(adapter, 'bot', bot)
base_params = {
'callback_token': 'callback-token',
'reply_target': {'target_type': 'person', 'target_id': '123'},
'request': {
'interaction_id': 'form-1',
'title': 'Choose priority',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'options': [{'value': 'urgent', 'label': 'Urgent'}],
}
],
'fallback_text': 'Reply with a priority.',
},
}
result = await adapter.call_platform_api('interaction.request', base_params)
assert result['rich'] is True
button = bot.send_message.await_args.kwargs['reply_markup'].inline_keyboard[0][0]
assert button.callback_data == 'lbi:callback-token:f:0:0'
base_params['request']['fields'][0]['type'] = 'text'
result = await adapter.call_platform_api('interaction.request', base_params)
assert result['rich'] is False
kwargs = bot.send_message.await_args.kwargs
assert 'reply_markup' not in kwargs
assert 'Reply with a priority.' in kwargs['text']
def test_telegram_interaction_callback_builds_scoped_host_event():
update = SimpleNamespace(
callback_query=SimpleNamespace(
message=SimpleNamespace(
chat=SimpleNamespace(id=-1001, type='supergroup'),
message_thread_id=7,
),
from_user=SimpleNamespace(id=456),
)
)
event = interaction_event_from_update(
update,
{'callback_token': 'callback-token', 'action_ref': 1},
)
assert event.action == 'interaction.submitted'
assert event.data['callback_token'] == 'callback-token'
assert event.data['action_ref'] == 1
assert event.data['actor_id'] == '456'
assert event.data['target_type'] == 'group'
assert event.data['target_id'] == '-1001#7'
@pytest.mark.asyncio
async def test_telegram_interaction_callback_answers_and_dispatches_once():
adapter = make_adapter()
listener = AsyncMock()
adapter.register_listener(platform_events.EBAEvent, listener)
query = SimpleNamespace(
data='lbi:callback-token:a:0',
answer=AsyncMock(),
edit_message_reply_markup=AsyncMock(),
message=SimpleNamespace(
chat=SimpleNamespace(id=123, type='private'),
message_thread_id=None,
),
from_user=SimpleNamespace(id=456),
)
update = SimpleNamespace(
message=None,
edited_message=None,
chat_member=None,
my_chat_member=None,
callback_query=query,
message_reaction=None,
)
callback_handler = next(
handler for handler in adapter.application.handlers[0] if isinstance(handler, CallbackQueryHandler)
)
await callback_handler.callback(update, None)
query.answer.assert_awaited_once_with()
query.edit_message_reply_markup.assert_awaited_once_with(reply_markup=None)
listener.assert_awaited_once()
event = listener.await_args.args[0]
assert event.action == 'interaction.submitted'
assert event.data['action_ref'] == 0
@pytest.mark.asyncio
async def test_telegram_converter_maps_message_and_edited_message_events():
update = make_update({'message': base_message_payload(text='hello @test_bot')})
@@ -187,6 +337,19 @@ async def test_telegram_converter_maps_message_and_edited_message_events():
assert str(edited_event.new_content) == 'edited'
@pytest.mark.asyncio
async def test_telegram_legacy_group_converter_preserves_actor_identity():
adapter = make_adapter()
group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'}
update = make_update({'message': base_message_payload(chat=group_chat)})
event = await adapter.legacy_event_converter.target2yiri(update, adapter.bot, 'test_bot')
assert isinstance(event, platform_events.GroupMessage)
assert event.sender.id == 456
assert event.sender.group.id == -100123
@pytest.mark.asyncio
async def test_telegram_converter_maps_non_message_updates():
chat_member = make_update(
@@ -11,6 +11,10 @@ from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.wecombot.interaction import (
build_interaction_card,
interaction_event_from_native,
)
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -44,6 +48,7 @@ class DummyWecomBotWsClient:
self.reply_text = AsyncMock(return_value={'reply': True})
self.push_stream_chunk = AsyncMock(return_value=True)
self.set_message = AsyncMock(return_value={'set': True})
self.send_template_card = AsyncMock(return_value={'sent': True})
def on_message(self, msg_type: str):
def decorator(func):
@@ -82,7 +87,7 @@ def manifest() -> dict:
/ 'wecombot'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter:
@@ -149,6 +154,52 @@ def test_wecombot_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions
def test_wecombot_interaction_card_uses_host_callback_token_and_index():
card = build_interaction_card(
{'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]},
'callback-token',
)
button = card['template_card']['button_list'][0]
assert button['key'] == 'lbi:callback-token:a:0'
@pytest.mark.asyncio
async def test_wecombot_interaction_delivery_and_callback_event():
adapter = make_adapter()
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Approve?',
'actions': [{'id': 'approve', 'label': 'Approve'}],
'fallback_text': 'Reply approve.',
},
},
)
assert result['rich'] is True
adapter.bot.send_template_card.assert_awaited_once()
native = wecombot_event(type='group', msgtype='event')
native['event'] = {
'eventtype': 'template_card_event',
'template_card_event': {'EventKey': 'lbi:callback-token:a:0'},
}
event = interaction_event_from_native(native)
assert event.action == 'interaction.submitted'
assert event.data['action_ref'] == 0
assert event.data['target_id'] == 'group-1'
native['event']['template_card_event'] = {'button': {'key': 'lbi:callback-token:f:0:2'}}
nested_event = interaction_event_from_native(native)
assert nested_event.data['field_ref'] == 0
assert nested_event.data['option_ref'] == 2
@pytest.mark.asyncio
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
content = await WecomBotMessageConverter.yiri2target(
@@ -271,4 +322,6 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
async def test_wecombot_webhook_mode_rejects_proactive_send():
adapter = make_adapter(enable_webhook=True)
with pytest.raises(NotSupportedError):
await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]))
await adapter.send_message(
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])
)