fix(agent): enable reliable cross-platform card interactions

This commit is contained in:
fdc310
2026-10-27 16:03:11 +08:00
parent 71b5b70633
commit de565d0bcc
11 changed files with 271 additions and 39 deletions
@@ -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'))