Files
LangBot/tests/unit_tests/agent/test_reply_stream.py

271 lines
11 KiB
Python

"""Explicit streaming delivery across SDK, Host lifecycle, and adapter boundaries."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from langbot_plugin.api.entities.builtin.platform import events, entities, message
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy
from langbot_plugin.api.proxies.agent_run.common import PermissionDeniedError
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot.pkg.agent.runner.reply_stream import ReplyStreamRequest, ReplyStreamSession
def make_session(*, native=True, source=True, mock=False):
event = SimpleNamespace(
delivery=SimpleNamespace(
surface='webui' if mock else 'platform',
platform_capabilities={'debug_mock': mock},
reply_target={'target_type': 'person', 'target_id': 'user-1'},
)
)
incoming = (
events.MessageReceivedEvent(
message_id='source-1',
sender=entities.User(id='user-1'),
chat_id='user-1',
chat_type=entities.ChatType.PRIVATE,
message_chain=message.MessageChain([message.Plain(text='hello')]),
source_platform_object=object(),
)
if source
else None
)
adapter = SimpleNamespace(
is_stream_output_supported=AsyncMock(return_value=native),
create_message_card=AsyncMock(return_value=True),
reply_message_chunk=AsyncMock(),
send_message=AsyncMock(),
reply_message=AsyncMock(),
)
return ReplyStreamSession(event, adapter, incoming), adapter, incoming
def request(key, operation='update', text='hello'):
return ReplyStreamRequest(stream_id=key, operation=operation, text=text)
def proxy_for(session, *, allowed=True, advertised=True):
if not hasattr(AgentRunAPIProxy, 'reply_stream'):
pytest.skip('SDK does not provide the optional streaming reply API')
context = SimpleNamespace(
run_id='run-1',
runtime=SimpleNamespace(deadline_at=None),
context=SimpleNamespace(available_apis=ContextAPICapabilities(reply_stream=advertised)),
resources=SimpleNamespace(
models=[],
knowledge_bases=[],
tools=[SimpleNamespace(tool_name='event_reply', operations=['call'])] if allowed else [],
),
)
async def action(action, data, timeout):
assert action == PluginToRuntimeAction.REPLY_STREAM
assert data['run_id'] == 'run-1'
return {
'result': await session.apply(
ReplyStreamRequest.model_validate({k: v for k, v in data.items() if k != 'run_id'})
)
}
transport = SimpleNamespace(call_action=AsyncMock(side_effect=action))
return AgentRunAPIProxy(context, transport), transport
@pytest.mark.parametrize(
'native,source,mock',
[
(True, True, False),
(False, True, False),
(True, False, False),
(True, True, True),
],
)
async def test_sdk_stream_reuses_adapter_or_sends_one_final_message(native, source, mock):
session, adapter, incoming = make_session(native=native, source=source, mock=mock)
api, transport = proxy_for(session)
async with api.reply_stream() as stream:
await stream.update('hello')
await stream.update('hello world')
adapter.send_message.assert_not_awaited()
adapter.reply_message.assert_not_awaited()
assert stream.result['status'] == 'completed'
assert stream.result['text'] == 'hello world'
assert transport.call_action.await_count == 3
if mock:
adapter.create_message_card.assert_not_awaited()
adapter.reply_message_chunk.assert_not_awaited()
adapter.send_message.assert_not_awaited()
assert stream.result['mock'] is True
elif native and source:
adapter.create_message_card.assert_awaited_once()
delivered_source = adapter.create_message_card.await_args.args[1]
assert delivered_source.source_platform_object is incoming.source_platform_object
chunks = adapter.reply_message_chunk.await_args_list
assert [c.kwargs['bot_message'].all_content for c in chunks] == ['hello', 'hello world', 'hello world']
assert [c.kwargs['is_final'] for c in chunks] == [False, False, True]
assert chunks[-1].kwargs['bot_message'].tool_calls is None
else:
adapter.reply_message_chunk.assert_not_awaited()
if source:
adapter.reply_message.assert_awaited_once()
assert adapter.reply_message.await_args.kwargs['message'][0].text == 'hello world'
else:
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'hello world'
await session.close()
@pytest.mark.parametrize('native', [True, False])
@pytest.mark.parametrize('error', [RuntimeError, asyncio.CancelledError])
async def test_exception_finalizes_visible_card_without_sending_buffered_partial(native, error):
session, adapter, _ = make_session(native=native)
api, _ = proxy_for(session)
with pytest.raises(error):
async with api.reply_stream() as stream:
await stream.update('partial')
raise error()
if native:
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
adapter.reply_message.assert_not_awaited()
adapter.send_message.assert_not_awaited()
count = adapter.reply_message_chunk.await_count
await session.close()
assert adapter.reply_message_chunk.await_count == count
@pytest.mark.parametrize('allowed,advertised', [(False, True), (True, False)])
async def test_missing_permission_or_old_host_fails_before_delivery(allowed, advertised):
session, _, _ = make_session()
api, transport = proxy_for(session, allowed=allowed, advertised=advertised)
with pytest.raises(PermissionDeniedError):
async with api.reply_stream():
pytest.fail('Not authorized')
transport.call_action.assert_not_awaited()
async def test_empty_stream_and_duplicate_finish_do_not_send_twice():
session, adapter, _ = make_session(native=False)
empty = uuid4()
await session.apply(request(empty, 'finish', ''))
adapter.reply_message.assert_not_awaited()
key = uuid4()
await session.apply(request(key))
first = await session.apply(request(key, 'finish'))
assert await session.apply(request(key, 'finish')) == first
adapter.reply_message.assert_awaited_once()
with pytest.raises(ValueError, match='closed'):
await session.apply(request(key))
await session.close()
with pytest.raises(ValueError, match='ended'):
await session.apply(request(uuid4()))
async def test_host_cleanup_closes_stream_when_plugin_disappears():
session, adapter, _ = make_session()
await session.apply(request(uuid4()))
await session.close()
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
async def test_uncertain_final_send_is_not_retried_by_finish_or_cleanup():
session, adapter, _ = make_session(native=False)
adapter.reply_message.side_effect = TimeoutError('Response lost')
key = uuid4()
with pytest.raises(TimeoutError):
await session.apply(request(key, 'finish'))
with pytest.raises(ValueError, match='closed'):
await session.apply(request(key, 'finish'))
await session.close()
adapter.reply_message.assert_awaited_once()
async def test_failed_update_closes_existing_card_during_run_cleanup():
session, adapter, _ = make_session()
adapter.reply_message_chunk.side_effect = [RuntimeError('update failed'), None]
with pytest.raises(RuntimeError):
await session.apply(request(uuid4()))
await session.close()
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
async def test_streams_are_isolated_by_run_and_bounded():
first, a, _ = make_session(native=False)
second, b, _ = make_session(native=False)
key = uuid4()
await first.apply(request(key, 'update', 'first'))
await second.apply(request(key, 'finish', 'second'))
assert b.reply_message.await_args.kwargs['message'][0].text == 'second'
a.reply_message.assert_not_awaited()
for _ in range(15):
await first.apply(request(uuid4(), 'finish', ''))
with pytest.raises(ValueError, match='at most'):
await first.apply(request(uuid4()))
await first.close()
a.reply_message.assert_not_awaited()
async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_stream():
from unittest.mock import Mock
from langbot_plugin.api.definition.components.event_processor import EventProcessor
session, adapter, incoming = make_session()
api, _ = proxy_for(session)
processor = EventProcessor()
processor.get_run_api = Mock(return_value=api)
@processor.handler(events.MessageReceivedEvent)
async def handle(ctx):
async with ctx.reply_stream() as stream:
await stream.update('one')
await stream.update('one two')
context = SimpleNamespace(
run_id='run-1',
config={},
event=SimpleNamespace(
data=incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
),
)
results = [result async for result in processor.run(context)]
assert [r.type for r in results] == ['tool.call.started', 'tool.call.completed', 'run.completed']
assert results[1].data['result']['text'] == 'one two'
assert adapter.reply_message_chunk.await_count == 3
await session.close()
async def test_shared_adapter_uses_host_ids_to_isolate_identical_plugin_stream_ids():
first, adapter, _ = make_session()
second, _, _ = make_session()
second.adapter = adapter
key = uuid4()
await first.apply(request(key, text='first'))
await second.apply(request(key, text='second'))
ids = [c.args[0] for c in adapter.create_message_card.await_args_list]
assert len(set(ids)) == 2
assert str(key) not in ids
await first.close()
await second.close()
async def test_run_cleanup_cancels_inflight_update_and_finalizes_card():
session, adapter, _ = make_session()
started = asyncio.Event()
async def update(**kwargs):
if not kwargs['is_final']:
started.set()
await asyncio.Event().wait()
adapter.reply_message_chunk.side_effect = update
task = asyncio.create_task(session.apply(request(uuid4())))
await asyncio.wait_for(started.wait(), 1)
await asyncio.wait_for(session.close(), 1)
with pytest.raises(asyncio.CancelledError):
await task
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True