mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-05 07:12:02 +08:00
fix(agent): enable reliable cross-platform card interactions
This commit is contained in:
@@ -250,6 +250,61 @@ async def test_agent_tool_card_uses_host_token_and_persists_resume_target(store,
|
||||
assert record['actor_id'] == 'user-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_tool_card_preserves_pipeline_authority(store, event):
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
manager = InteractionManager(SimpleNamespace(), store=store)
|
||||
event.delivery.interactions = InteractionDeliveryCapabilities(
|
||||
field_types=['select'],
|
||||
action_styles=['default', 'primary', 'danger'],
|
||||
supports_updates=True,
|
||||
max_fields=1,
|
||||
)
|
||||
conversation = SimpleNamespace(pipeline_uuid='pipeline-1')
|
||||
execution_query = SimpleNamespace(
|
||||
pipeline_uuid='pipeline-1',
|
||||
pipeline_config=CONFIG,
|
||||
session=SimpleNamespace(using_conversation=conversation),
|
||||
)
|
||||
session = {
|
||||
'run_id': 'pipeline-tool-run-1',
|
||||
'runner_id': 'plugin:test/ApprovalRunner/default',
|
||||
'execution_query': execution_query,
|
||||
'authorization': {
|
||||
'binding_id': 'pipeline-binding-1',
|
||||
'processor_type': 'pipeline',
|
||||
'processor_id': 'pipeline-1',
|
||||
'interactions_enabled': True,
|
||||
'bot_id': 'bot-1',
|
||||
'workspace_id': 'workspace-1',
|
||||
'thread_id': None,
|
||||
'platform_context': {
|
||||
'actor': {'actor_type': 'user', 'actor_id': 'user-1'},
|
||||
'delivery': event.delivery.model_dump(mode='json'),
|
||||
},
|
||||
},
|
||||
}
|
||||
request = {
|
||||
'interaction_id': 'pipeline-card-host-generated',
|
||||
'kind': 'choice',
|
||||
'title': 'Continue?',
|
||||
'actions': [
|
||||
{'id': 'confirm', 'label': 'Confirm', 'style': 'primary'},
|
||||
{'id': 'cancel', 'label': 'Cancel', 'style': 'default'},
|
||||
],
|
||||
'fallback_text': 'Reply confirm or cancel.',
|
||||
}
|
||||
|
||||
result = await manager.present_tool_request(request_payload=request, session=session, adapter=adapter)
|
||||
|
||||
assert result['status'] == 'pending'
|
||||
record = await store.get_request('pipeline-tool-run-1', 'pipeline-card-host-generated')
|
||||
assert record is not None
|
||||
assert record['processor_type'] == 'pipeline'
|
||||
assert record['processor_id'] == 'pipeline-1'
|
||||
assert record['binding_id'] == 'pipeline-binding-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuous_interaction_reuses_submitted_platform_presentation(store, event, binding):
|
||||
adapter = FakeAdapter(supports_interactions=True)
|
||||
|
||||
@@ -12,7 +12,13 @@ from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy
|
||||
from langbot.pkg.agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
AgentEventEnvelope,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
ResourcePolicy,
|
||||
)
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.provider.tools.toolmgr import ToolManager
|
||||
|
||||
@@ -240,6 +246,62 @@ async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_receives_interactive_card_tool_for_supported_adapter(app):
|
||||
query = make_query({})
|
||||
query.adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['interaction.request'],
|
||||
get_interaction_capabilities=lambda: {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 10,
|
||||
},
|
||||
)
|
||||
|
||||
resources = await build_resources(
|
||||
app,
|
||||
query,
|
||||
make_descriptor(capabilities={'tool_calling': True}),
|
||||
)
|
||||
|
||||
assert [tool['tool_name'] for tool in resources['tools']] == ['present_interactive_card']
|
||||
assert resources['platform_capabilities']['authorized_tools'] == ['present_interactive_card']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_processor_cannot_receive_interactive_card_tool(app):
|
||||
event = AgentEventEnvelope(
|
||||
event_id='event-processor-card',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
input=AgentInput(text='hello'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={'target_type': 'person', 'target_id': 'user-1'},
|
||||
platform_capabilities={'supported_apis': ['interaction.request']},
|
||||
),
|
||||
)
|
||||
binding = AgentBinding(
|
||||
binding_id='event-processor-binding',
|
||||
scope=BindingScope(scope_type='global'),
|
||||
runner_id=RUNNER_ID,
|
||||
processor_type='event_processor',
|
||||
resource_policy=ResourcePolicy(allowed_platform_tool_names=['present_interactive_card']),
|
||||
delivery_policy=DeliveryPolicy(enable_interactions=True),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
execution_context=TEST_CONTEXT,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=make_descriptor(capabilities={'tool_calling': True}),
|
||||
)
|
||||
|
||||
assert resources['tools'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
"""Config-selected model references are projected regardless of method granularity."""
|
||||
|
||||
@@ -191,26 +191,27 @@ def test_qqofficial_interaction_keyboard_contains_only_host_token_and_index():
|
||||
@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.',
|
||||
},
|
||||
params = {
|
||||
'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.',
|
||||
},
|
||||
}
|
||||
result = await adapter.call_platform_api('interaction.request', params)
|
||||
await adapter.call_platform_api('interaction.request', params)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True}
|
||||
assert adapter.bot.send_markdown_keyboard.await_args.kwargs['msg_id'] == 'msg-1'
|
||||
calls = adapter.bot.send_markdown_keyboard.await_args_list
|
||||
assert [call.kwargs['msg_id'] for call in calls] == ['msg-1', 'msg-1']
|
||||
assert [call.kwargs['msg_seq'] for call in calls] == [1, 2]
|
||||
|
||||
event = interaction_event_from_payload(
|
||||
{
|
||||
@@ -226,6 +227,34 @@ async def test_qqofficial_interaction_delivery_and_callback_event():
|
||||
assert event.data['actor_id'] == 'user-openid'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_interaction_omits_synthetic_negative_message_id():
|
||||
adapter = make_adapter()
|
||||
|
||||
await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {
|
||||
'target_type': 'person',
|
||||
'target_id': 'user-openid',
|
||||
'message_id': -1,
|
||||
},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
call = adapter.bot.send_markdown_keyboard.await_args
|
||||
assert call.kwargs['msg_id'] is None
|
||||
assert call.kwargs['event_id'] is None
|
||||
assert call.kwargs['msg_seq'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
|
||||
payload = await QQOfficialMessageConverter.yiri2target(
|
||||
|
||||
@@ -605,6 +605,19 @@ class TestInteractionResumeRouting:
|
||||
assert submission['action_ref'] == 1
|
||||
assert submission['interaction_id'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replayed_callback_is_an_idempotent_noop(self):
|
||||
from langbot.pkg.agent.runner.interaction_store import InteractionAlreadySubmittedError
|
||||
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
manager = bot.ap.agent_run_orchestrator.interaction_manager
|
||||
manager.consume_callback.side_effect = InteractionAlreadySubmittedError('Interaction was already submitted')
|
||||
|
||||
await bot._handle_interaction_submission(self._event(), SimpleNamespace())
|
||||
|
||||
manager.acknowledge_submission.assert_not_awaited()
|
||||
bot.ap.msg_aggregator.add_message.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_callback_rejects_changed_runner(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
|
||||
Reference in New Issue
Block a user