mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(runtime): preserve explicit replies and report bot configuration errors
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user