mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
fix: stabilize native streaming and improve bot controls
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from tests.unit_tests.box.test_box_service import make_app, make_query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_without_openat_uses_sandbox_attachment_channel(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr('os.supports_dir_fd', set())
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
service._tenant_workspace = Mock(return_value=str(tmp_path))
|
||||
service._read_outbox_host = Mock(side_effect=AssertionError('POSIX host reader must not run'))
|
||||
service._read_outbox_via_exec = AsyncMock(return_value=[])
|
||||
service._clear_outbox = AsyncMock()
|
||||
query = make_query()
|
||||
assert service._host_query_dir(service.OUTBOX_SUBDIR, query) is None
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
service._read_outbox_via_exec.assert_awaited_once_with(query)
|
||||
service._clear_outbox.assert_awaited_once_with(query, None)
|
||||
@@ -263,6 +263,57 @@ class TestChatMessageHandlerReal:
|
||||
class TestChatHandlerStreaming:
|
||||
"""Tests for streaming behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('with_tool_call', [False, True])
|
||||
async def test_terminal_delta_and_snapshot_close_card_once(
|
||||
self, fake_app, mock_event_ctx, set_runner, with_tool_call
|
||||
):
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
|
||||
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
query = text_query('stream test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
query.adapter.create_message_card = AsyncMock()
|
||||
attachments = MessageChain([File(url='https://example.com/file.txt', name='file.txt')])
|
||||
|
||||
class StreamRunner:
|
||||
def __init__(self, app, config):
|
||||
pass
|
||||
|
||||
async def run(self, query):
|
||||
if with_tool_call:
|
||||
yield MessageChunk(
|
||||
role='assistant',
|
||||
is_final=True,
|
||||
tool_calls=[
|
||||
{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}
|
||||
],
|
||||
)
|
||||
yield MessageChunk(role='assistant', content='hello')
|
||||
yield MessageChunk(role='assistant', content='hello world', is_final=True)
|
||||
completed = Message(role='assistant', content='hello world')
|
||||
completed.attachments = attachments
|
||||
yield completed
|
||||
|
||||
set_runner(StreamRunner)
|
||||
handler = get_chat_handler().ChatMessageHandler(fake_app)
|
||||
handler._ensure_conversation_for_history = AsyncMock()
|
||||
delivered = []
|
||||
async for stage_result in handler.handle(query):
|
||||
assert stage_result.result_type == get_entities().ResultType.CONTINUE
|
||||
delivered.append(query.resp_messages[-1])
|
||||
assert [result.content for result in delivered if result.content] == ['hello', 'hello world']
|
||||
if with_tool_call:
|
||||
assert delivered[0].tool_calls
|
||||
assert not delivered[0].is_final
|
||||
assert sum(result.is_final for result in delivered) == 1
|
||||
assert delivered[-1].attachments is attachments
|
||||
assert len({result.resp_message_id for result in delivered}) == 1
|
||||
query.adapter.create_message_card.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""Streaming produces multiple results."""
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
|
||||
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
|
||||
|
||||
|
||||
async def source(*items):
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_snapshot_is_delivered_once_with_attachments():
|
||||
text = '<think>reasoning</think>answer'
|
||||
completed = Message(role='assistant', content=text)
|
||||
completed.attachments = MessageChain([File(url='https://example.com/report.txt', name='report.txt')])
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', content='answer', all_content=text, is_final=True),
|
||||
completed,
|
||||
)
|
||||
)
|
||||
]
|
||||
assert results == [completed]
|
||||
assert results[0].attachments is completed.attachments
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('with_snapshot', [False, True])
|
||||
async def test_progressive_chunks_are_not_buffered(with_snapshot):
|
||||
first = MessageChunk(role='assistant', content='hel', all_content='hel')
|
||||
final = MessageChunk(role='assistant', content='lo', all_content='hello', is_final=True)
|
||||
completed = Message(role='assistant', content='hello')
|
||||
stream = coalesce_stream_results(source(first, final, *([completed] if with_snapshot else [])))
|
||||
assert await anext(stream) == first
|
||||
assert [item async for item in stream] == [completed if with_snapshot else final]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_only_and_distinct_messages_are_preserved():
|
||||
final = MessageChunk(role='assistant', content='first', is_final=True)
|
||||
other = Message(role='assistant', content='second')
|
||||
results = [item async for item in coalesce_stream_results(source(final, other))]
|
||||
assert [item.content for item in results] == ['first', 'second']
|
||||
assert [item async for item in coalesce_stream_results(source(other))] == [other]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_terminal_delta_uses_completed_snapshot():
|
||||
completed = Message(role='assistant', content='answer')
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', is_final=True),
|
||||
completed,
|
||||
)
|
||||
)
|
||||
]
|
||||
assert results == [completed]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failures_propagate_without_successful_final():
|
||||
async def failing():
|
||||
yield MessageChunk(role='assistant', content='partial')
|
||||
raise RuntimeError('provider failed')
|
||||
|
||||
stream = coalesce_stream_results(failing())
|
||||
assert (await anext(stream)).content == 'partial'
|
||||
with pytest.raises(RuntimeError, match='provider failed'):
|
||||
await anext(stream)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cumulative_stream_ends_with_last_snapshot_even_when_final_is_blank():
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', content='hel'),
|
||||
MessageChunk(role='assistant', content='hello'),
|
||||
MessageChunk(role='assistant', is_final=True),
|
||||
)
|
||||
)
|
||||
]
|
||||
assert [item.all_content for item in results] == ['hel', 'hello', 'hello']
|
||||
assert results[-1].is_final
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user