fix(discord): complete streaming media and event delivery

This commit is contained in:
RockChinQ
2026-10-17 15:11:29 +08:00
parent bbf517edbc
commit c05aac538f
14 changed files with 407 additions and 105 deletions
@@ -207,6 +207,19 @@ def test_platform_resources_require_runner_call_permission() -> None:
assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}]
def test_interaction_resume_can_reply_only_to_a_frozen_delivery_target() -> None:
event = _event().model_copy(update={'event_type': 'interaction.submitted'})
names = resolve_agent_platform_tool_names({}, event.event_type)
assert 'event_reply' in names
resources, _ = build_platform_tool_resources(event, names, ['detail', 'call'])
assert 'event_reply' in {item['tool_name'] for item in resources}
event.delivery.reply_target = None
resources, capabilities = build_platform_tool_resources(event, names, ['detail', 'call'])
assert 'event_reply' not in {item['tool_name'] for item in resources}
assert {'name': 'event_reply', 'reason': 'event_target_unavailable'} in capabilities['unavailable_tools']
@pytest.mark.asyncio
@pytest.mark.parametrize(
('name', 'params', 'api'),
+18 -2
View File
@@ -234,7 +234,8 @@ class TestPreProcessorImageSegment:
"""Tests for image segment handling."""
@pytest.mark.asyncio
async def test_image_with_vision_model(self):
@pytest.mark.parametrize('image_source', ['base64', 'url'])
async def test_image_with_vision_model(self, image_source):
"""Image should be included when model supports vision."""
preproc = get_preproc_module()
@@ -268,7 +269,13 @@ class TestPreProcessorImageSegment:
chain = platform_message.MessageChain(
[
platform_message.Plain(text='look at this'),
platform_message.Image(base64='data:image/png;base64,abc123'),
platform_message.Image(
**(
{'base64': 'data:image/png;base64,abc123'}
if image_source == 'base64'
else {'url': 'https://cdn.example/image.png'}
)
),
]
)
query.message_chain = chain
@@ -278,6 +285,15 @@ class TestPreProcessorImageSegment:
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
contents = [part.model_dump() for part in result.new_query.user_message.content]
attachments = QueryEntryAdapter._build_attachments(result.new_query, contents)
assert len(attachments) == 1
if image_source == 'url':
assert attachments[0]['url'] == 'https://cdn.example/image.png'
else:
assert attachments[0].get('content') or attachments[0].get('url')
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
@@ -563,3 +563,56 @@ async def test_valid_interaction_success_keeps_acknowledgement_and_execution():
assert chain['outcome'] == 'success'
assert {node['family'] for node in execution_nodes(records)} == {'platform_event', 'platform_api', 'runner'}
await counters.shutdown()
@pytest.mark.parametrize('channel_id', ['channel-1', 'channel-2'])
def test_discord_reply_target_uses_channel_and_preserves_guild(channel_id):
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.entities.builtin.platform import entities, events, message
event = events.MessageReceivedEvent(
message_id='message-1',
message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1'),
chat_type=entities.ChatType.GROUP,
chat_id=channel_id,
group=entities.UserGroup(id='guild-1'),
adapter_name='discord-omni',
)
assert RuntimeBot._infer_reply_target(event) == (
'group',
channel_id,
{'group_id': 'guild-1', 'chat_id': channel_id},
)
def test_group_event_without_chat_retains_group_reply_target():
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.entities.builtin.platform import entities, events
event = events.MemberJoinedEvent(group=entities.UserGroup(id='group-1'), member=entities.User(id='user-1'))
assert RuntimeBot._infer_reply_target(event) == ('group', 'group-1', {'group_id': 'group-1'})
def test_agent_event_keeps_image_file_and_voice_attachments():
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.entities.builtin.platform import entities, events, message
event = events.MessageReceivedEvent(
message_id='media-1',
message_chain=message.MessageChain(
[
message.Image(url='https://cdn.example.test/image.png'),
message.File(name='input.txt', url='https://cdn.example.test/input.txt'),
message.Voice(base64='dGVzdA=='),
]
),
sender=entities.User(id='user-1'),
chat_type=entities.ChatType.PRIVATE,
chat_id='channel-1',
)
agent_input = RuntimeBot._build_agent_input(event)
assert [attachment.type for attachment in agent_input.attachments] == ['image', 'file', 'voice']
assert agent_input.attachments[1].name == 'input.txt'
assert agent_input.attachments[1].url == 'https://cdn.example.test/input.txt'
assert agent_input.attachments[2].content == 'dGVzdA=='
@@ -344,3 +344,153 @@ async def test_discord_send_and_reply_omit_empty_files_and_return_message_result
assert channel.send.await_args.kwargs['content'] == 'reply'
assert channel.send.await_args.kwargs['reference'] is source_message
assert channel.send.await_args.kwargs['mention_author'] is False
def stream_adapter():
adapter = make_adapter()
sent = SimpleNamespace(content='', edit=AsyncMock())
sent.edit.return_value = sent
channel = SimpleNamespace(send=AsyncMock(return_value=sent))
adapter._stream_buffer['stream'] = {
'channel': channel,
'sent_message': None,
'last_content': '',
'chunk_count': 0,
'updated_at': 0,
}
return adapter, channel, sent
@pytest.mark.asyncio
async def test_discord_stream_delivers_all_text_across_message_limit():
adapter, channel, sent = stream_adapter()
source = platform_events.MessageReceivedEvent(message_id=1)
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'stream'},
platform_message.MessageChain([platform_message.Plain(text='preview')]),
)
sent.content = 'preview'
content = '0123456789' * 450 + 'END'
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'stream'},
platform_message.MessageChain([platform_message.Plain(text=content)]),
is_final=True,
)
sent.edit.assert_awaited_once_with(content=content[:2000])
remainder = ''.join(call.args[0] for call in channel.send.await_args_list[1:])
assert content[:2000] + remainder == content
assert all(len(call.args[0]) <= 2000 for call in channel.send.await_args_list)
assert not adapter._stream_buffer
@pytest.mark.asyncio
async def test_discord_stream_keeps_tool_round_and_delivers_terminal_attachment():
adapter, channel, sent = stream_adapter()
object.__setattr__(adapter, 'reply_message', AsyncMock())
source = platform_events.MessageReceivedEvent(message_id=1)
attachment = platform_message.File(name='result.txt', base64='T0s=')
message = platform_message.MessageChain([attachment])
await adapter.reply_message_chunk(
source,
{'resp_message_id': 'stream', 'tool_calls': [{'id': 'tool'}]},
message,
is_final=True,
)
assert 'stream' in adapter._stream_buffer
adapter.reply_message.assert_not_awaited()
await adapter.reply_message_chunk(source, {'resp_message_id': 'stream'}, message, is_final=True)
adapter.reply_message.assert_awaited_once()
assert adapter.reply_message.await_args.args[1].root == [attachment]
channel.send.assert_not_awaited()
assert not adapter._stream_buffer
@pytest.mark.asyncio
async def test_discord_stream_final_failure_is_not_reported_as_success():
adapter, channel, _ = stream_adapter()
channel.send.side_effect = RuntimeError('delivery failed')
with pytest.raises(RuntimeError, match='delivery failed'):
await adapter.reply_message_chunk(
platform_events.MessageReceivedEvent(message_id=1),
{'resp_message_id': 'stream'},
platform_message.MessageChain([platform_message.Plain(text='reply')]),
is_final=True,
)
assert not adapter._stream_buffer
@pytest.mark.asyncio
async def test_discord_gateway_uses_one_raw_path_for_cached_events():
adapter = make_adapter()
object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock())
assert not hasattr(adapter.bot, 'on_reaction_add')
assert not hasattr(adapter.bot, 'on_reaction_remove')
assert not hasattr(adapter.bot, 'on_message_delete')
payload = SimpleNamespace(user_id=123)
await adapter.bot.on_raw_reaction_add(payload)
adapter._dispatch_gateway_tuple.assert_awaited_once_with('raw_reaction_add', payload, None)
adapter._dispatch_gateway_tuple.reset_mock()
cached = fake_message()
await adapter.bot.on_raw_message_delete(SimpleNamespace(cached_message=cached))
adapter._dispatch_gateway_tuple.assert_awaited_once_with('message_delete', cached, None)
@pytest.mark.asyncio
async def test_discord_does_not_route_its_own_stream_edits():
adapter = make_adapter()
object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock())
before = fake_message(author=fake_user(bot=True))
await adapter.bot.on_message_edit(before, before)
adapter._dispatch_gateway_tuple.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_uncached_edit_fetches_message_without_duplicate_or_preview_events():
adapter = make_adapter()
after = fake_message()
channel = SimpleNamespace(fetch_message=AsyncMock(return_value=after))
object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel))
object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock())
payload = SimpleNamespace(cached_message=None, data={'content': 'edited'}, channel_id=12, message_id=34)
await adapter.bot.on_raw_message_edit(payload)
channel.fetch_message.assert_awaited_once_with(34)
adapter._dispatch_gateway_tuple.assert_awaited_once_with('message_edit', (None, after), None)
adapter._dispatch_gateway_tuple.reset_mock()
channel.fetch_message.reset_mock()
payload.cached_message = after
await adapter.bot.on_raw_message_edit(payload)
payload.cached_message = None
payload.data = {'embeds': []}
await adapter.bot.on_raw_message_edit(payload)
payload.data = {'content': 'bot edit', 'author': {'bot': True}}
await adapter.bot.on_raw_message_edit(payload)
channel.fetch_message.assert_not_awaited()
adapter._dispatch_gateway_tuple.assert_not_awaited()
@pytest.mark.asyncio
async def test_guild_event_reply_uses_system_channel(monkeypatch):
adapter = make_adapter()
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=456)))
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=channel))
adapter._get_channel = AsyncMock()
result = await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
assert result.message_id == 456
channel.send.assert_awaited_once_with(content='Welcome')
adapter._get_channel.assert_not_awaited()
@pytest.mark.asyncio
async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(monkeypatch):
adapter = make_adapter()
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=None))
adapter._get_channel = AsyncMock()
with pytest.raises(ValueError, match='no system channel'):
await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
adapter._get_channel.assert_not_awaited()
@@ -645,7 +645,8 @@ class TestInteractionResumeRouting:
send_message=AsyncMock(),
)
await bot._handle_interaction_submission(self._event(), adapter)
event = self._event()
await bot._handle_interaction_submission(event, adapter)
envelope, binding, adapter_context = captured[0]
assert envelope.event_type == 'interaction.submitted'
@@ -653,7 +654,11 @@ class TestInteractionResumeRouting:
assert binding.binding_id == 'binding-1'
assert binding.processor_type == 'agent'
assert binding.processor_id == 'agent-1'
assert adapter_context == {'_delivery_adapter': adapter}
assert adapter_context == {
'_delivery_adapter': adapter,
'_platform_event': event,
'_execution_context': TEST_CONTEXT,
}
assert envelope.delivery.supports_streaming is False
assert binding.delivery_policy.enable_reply is False
adapter.send_message.assert_not_awaited()
+33
View File
@@ -5,6 +5,7 @@ Tests handler helper methods that don't require full handler setup.
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
@@ -18,6 +19,38 @@ from langbot_plugin.entities.io.context import (
)
@pytest.mark.asyncio
async def test_plugin_reset_clears_runner_history_in_the_authorized_scope(monkeypatch):
reset = AsyncMock()
monkeypatch.setattr('langbot.pkg.agent.runner.context_reset.reset_runner_context', reset)
transactions = []
@asynccontextmanager
async def tenant_uow(workspace):
transactions.append(workspace)
yield
query = SimpleNamespace(
bot_uuid='bot-a',
launcher_type=SimpleNamespace(value='group'),
launcher_id='guild-a',
sender_id='user-a',
variables={},
session=SimpleNamespace(using_conversation=SimpleNamespace(uuid=None)),
)
app = SimpleNamespace(
query_pool=SimpleNamespace(cached_queries={'query-a': query}),
persistence_mgr=SimpleNamespace(tenant_uow=tenant_uow),
logger=SimpleNamespace(debug=Mock(), warning=Mock()),
)
h = make_handler(app)
response = await h.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value]({'query_id': 'query-a'})
assert response.code == 0
reset.assert_awaited_once_with(app.persistence_mgr, 'workspace-a', 'bot-a', 'group_guild-a')
assert transactions == ['workspace-a']
assert query.session.using_conversation is None
def make_handler(app):
"""Create a RuntimeConnectionHandler with mocked external connection."""
from langbot.pkg.plugin.handler import RuntimeConnectionHandler