fix: stabilize native streaming and improve bot controls

This commit is contained in:
Hyu
2026-10-10 18:02:42 +08:00
parent e8da99be63
commit a2b7f6b7bb
32 changed files with 583 additions and 55 deletions
@@ -301,6 +301,7 @@ async def test_lark_event_converter_maps_group_and_private_message():
@pytest.mark.asyncio
async def test_lark_adapter_dispatches_and_caches_message_event():
adapter = make_adapter()
adapter._get_bot_open_id = AsyncMock(return_value='bot-open-id')
calls: list[platform_events.Event] = []
async def listener(event, adapter):
@@ -0,0 +1,85 @@
"""Exercise terminal runner snapshots against native adapter delivery methods."""
import datetime
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import telegram
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
@pytest.mark.asyncio
@pytest.mark.parametrize('platform', ['lark', 'dingtalk', 'telegram', 'discord', 'qqofficial', 'wecombot'])
async def test_native_stream_receives_one_terminal_snapshot(platform):
module = __import__(f'tests.unit_tests.platform.test_{platform}_eba_adapter', fromlist=['make_adapter'])
adapter = module.make_adapter()
object.__setattr__(adapter, 'reply_message', AsyncMock())
source = SimpleNamespace()
if platform == 'lark':
adapter.card_id_dict['response'] = 'card'
adapter.card_sequence_dict['card'] = 0
adapter._replace_streaming_card = AsyncMock()
source.source_platform_object = None
elif platform == 'dingtalk':
adapter.card_instance_id_dict['response'] = ('card', 'id')
elif platform == 'telegram':
source.source_platform_object = telegram.Update(
1,
message=telegram.Message(
1,
datetime.datetime.now(datetime.timezone.utc),
telegram.Chat(1, 'private'),
text='hi',
),
)
adapter.bot = SimpleNamespace(send_message_draft=AsyncMock(), send_message=AsyncMock())
adapter.msg_stream_id['response'] = ('private', 1, False)
elif platform == 'discord':
adapter._stream_buffer['response'] = {
'channel': SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(content='hello', edit=AsyncMock()))),
'sent_message': None,
'last_content': '',
'chunk_count': 0,
'updated_at': time.time(),
}
channel = adapter._stream_buffer['response']['channel']
elif platform == 'qqofficial':
source = await module.QQOfficialEventConverter().target2yiri(module.qq_event())
await adapter.create_message_card('response', source)
else:
source = await module.WecomBotEventConverter().target2yiri(module.wecombot_event())
async def runner():
yield MessageChunk(role='assistant', content='hello', all_content='hello', is_final=True)
yield Message(role='assistant', content='hello')
count = 0
async for result in coalesce_stream_results(runner()):
chunk = MessageChunk(role='assistant', content=result.content, resp_message_id='response', is_final=True)
await adapter.reply_message_chunk(source, chunk, MessageChain([Plain(text=result.content)]), is_final=True)
count += 1
assert count == 1
adapter.reply_message.assert_not_awaited()
if platform == 'lark':
adapter._replace_streaming_card.assert_awaited_once_with('card', 'hello')
assert not adapter.card_id_dict
elif platform == 'dingtalk':
adapter.bot.send_card_message.assert_awaited_once_with('card', 'id', 'hello', True)
assert not adapter.card_instance_id_dict
elif platform == 'telegram':
adapter.bot.send_message.assert_awaited_once()
assert not adapter.msg_stream_id
elif platform == 'discord':
channel.send.assert_awaited_once_with('hello')
assert not adapter._stream_buffer
elif platform == 'qqofficial':
assert len([call for call in adapter.bot.sent if call[0] == 'stream']) == 1
assert not adapter._stream_ctx
else:
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
adapter.bot.reply_text.assert_not_awaited()
@@ -241,7 +241,7 @@ async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba()
assert private_event.adapter_name == 'wecombot-omni'
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'user-1'
assert str(private_event.message_chain) == 'hello'
assert str(private_event.message_chain) == '@EBA Bot hello'
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.chat_type == platform_entities.ChatType.GROUP
@@ -302,7 +302,9 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
source_event = await WecomBotEventConverter().target2yiri(wecombot_event())
await adapter.reply_message(source_event, message)
adapter.bot.reply_text.assert_awaited_once_with('req-1', 'hello')
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
adapter.bot.reply_text.assert_not_awaited()
adapter.bot.push_stream_chunk.reset_mock()
await adapter.reply_message_chunk(source_event, None, message, is_final=True)
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
@@ -117,3 +117,61 @@ async def test_real_final_chunk_is_sent_and_closes_stream():
assert frames[-1]['stream']['content'] == 'the answer'
assert frames[-1]['stream']['finish'] is True
assert not client.stream_still_open()
@pytest.mark.asyncio
async def test_duplicate_final_is_acknowledged_without_sending_again():
client = RecordingClient()
client.seed_stream()
assert await client.push_stream_chunk('msg-1', 'answer', is_final=True)
assert await client.push_stream_chunk('msg-1', 'answer', is_final=True)
assert len(client.stream_frames()) == 1
assert not await client.push_stream_chunk('different-message', 'answer', is_final=True)
assert not await client.push_stream_chunk('msg-1', 'different answer', is_final=True)
@pytest.mark.asyncio
async def test_runner_cumulative_snapshots_reach_wire_once_without_concatenation():
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
from tests.unit_tests.platform.test_wecombot_eba_adapter import (
make_adapter,
wecombot_event,
WecomBotEventConverter,
)
client = RecordingClient()
client.seed_stream()
adapter = make_adapter()
adapter.bot = client
event = await WecomBotEventConverter().target2yiri(wecombot_event())
snapshots = ['<think>test', '<think>test</think>\nOK', '<think>test</think>\nOK!']
async def runner():
# LocalAgent's StreamingModelCaller sets content to the accumulated
# text, leaves all_content unset, then emits message.completed.
for index, text in enumerate(snapshots):
yield MessageChunk(role='assistant', content=text, msg_sequence=index + 1, is_final=index == 2)
yield Message(role='assistant', content=snapshots[-1])
async for result in coalesce_stream_results(runner()):
final = isinstance(result, Message) or result.is_final
if isinstance(result, MessageChunk):
result = result.model_copy(update={'content': result.all_content})
await adapter.reply_message_chunk(event, result, result.get_content_platform_message_chain(), is_final=final)
frames = client.stream_frames()
from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text
assert [frame['stream']['content'] for frame in frames] == [format_stream_text(text) for text in snapshots]
assert [frame['stream']['finish'] for frame in frames] == [False, False, True]
assert len(client.replies) == 3 # No extra plain-text reply after completion.
@pytest.mark.asyncio
async def test_revised_snapshot_replaces_previous_text():
client = RecordingClient()
client.seed_stream()
await client.push_stream_chunk('msg-1', '<think>test</think>\nanswer')
await client.push_stream_chunk('msg-1', 'answer', is_final=True)
assert client.stream_frames()[-1]['stream']['content'] == 'answer'
@@ -0,0 +1,30 @@
import pytest
from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text
def test_multiple_rounds_have_one_thinking_section_and_separated_answers():
raw = '<think>one</think>Checking.<think>two</think>More checks.<think>three</think>Done.'
result = format_stream_text(raw)
assert result == '<think>\none\n\ntwo\n\nthree\n</think>\n\nChecking.\n\nMore checks.\n\nDone.'
assert format_stream_text(result) == result
@pytest.mark.parametrize('suffix', ['<', '<t', '<thi', '<think', '</', '</thi'])
def test_partial_tags_are_not_shown_in_answer(suffix):
result = format_stream_text('<think>one</think>Checking.' + suffix)
assert result.endswith('Checking.')
def test_later_incomplete_thinking_remains_inside_single_section():
result = format_stream_text('<think>one</think>Checking.<think>two')
assert result == '<think>\none\n\ntwo\n</think>\n\nChecking.'
def test_plain_markdown_is_unchanged():
text = ' hello\n\n```python\na < b\n```\n'
assert format_stream_text(text) == text
def test_initial_partial_thinking_tag_waits_for_next_snapshot():
assert format_stream_text('<thi') == ''
@@ -369,11 +369,13 @@ async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '<think>test</think>你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
assert sent == [
('req-1', 'stream-1', '你', False),
('req-1', 'stream-1', '你好', False),
('req-1', 'stream-1', '<think>test</think>你好', False),
('req-1', 'stream-1', '你好', True),
]
@@ -385,6 +387,7 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '<think>test</think>你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
assert session.queue.qsize() == 1