Files
LangBot/tests/unit_tests/api/test_application_workspace_streams.py
T

159 lines
6.0 KiB
Python

"""Streaming lifetimes: debug cancellation versus durable Assistant execution."""
from __future__ import annotations
import asyncio
import contextvars
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot.pkg.api.application.service_operations import TurnInput, _assistant_service
from langbot.pkg.api.application.workspace_streams import AgentDebugInput, agent_debug, assistant_turn
from langbot.pkg.api.http.service.assistant import AssistantError
from tests.unit_tests.api.test_management_assistant import context
pytest_plugins = ['tests.unit_tests.api.test_management_assistant']
def scoped_app(debug):
active = contextvars.ContextVar('debug_stream_scope', default=None)
@asynccontextmanager
async def tenant_scope(workspace_uuid):
token = active.set((workspace_uuid, asyncio.current_task()))
try:
yield
finally:
active.reset(token)
return SimpleNamespace(
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
agent_service=SimpleNamespace(debug_agent=debug),
), active
@pytest.mark.asyncio
async def test_debug_stream_delivers_callbacks_before_completion_and_propagates_failure():
release = asyncio.Event()
async def debug(ctx, agent_uuid, payload, on_result):
assert active.get() == (ctx.workspace_uuid, asyncio.current_task())
await on_result({'text': payload['text']})
await release.wait()
return {'agent_uuid': agent_uuid, 'status': 'completed'}
ap, active = scoped_app(debug)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={'text': 'partial'}))
assert await anext(stream) == {'kind': 'result', 'data': {'text': 'partial'}}
assert active.get() is None
release.set()
assert await anext(stream) == {'kind': 'completed', 'data': {'agent_uuid': 'agent', 'status': 'completed'}}
await stream.aclose()
async def failed(*args, **kwargs):
raise ValueError('invalid debug input')
ap.agent_service.debug_agent = failed
failed_stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
with pytest.raises(ValueError, match='invalid debug input'):
await anext(failed_stream)
@pytest.mark.asyncio
async def test_debug_stream_applies_backpressure_and_close_cancels_producer():
completed_callbacks = 0
saturated = asyncio.Event()
cancelled = asyncio.Event()
async def debug(ctx, agent_uuid, payload, on_result):
nonlocal completed_callbacks
try:
for index in range(100):
if index == 33:
saturated.set()
await on_result({'index': index})
completed_callbacks += 1
finally:
cancelled.set()
ap, _ = scoped_app(debug)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
assert (await anext(stream))['data']['index'] == 0
await asyncio.wait_for(saturated.wait(), 1)
assert completed_callbacks == 33
assert not cancelled.is_set()
await stream.aclose()
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_assistant_stream_disconnect_preserves_run_and_bounded_queue(assistant):
_, ap, provider = assistant
service = _assistant_service(ap)
release = asyncio.Event()
async def model_stream(**kwargs):
yield MessageChunk(role='assistant', content='first')
await release.wait()
for _ in range(100):
yield MessageChunk(role='assistant', content='x')
yield MessageChunk(role='assistant', content=' done', is_final=True)
provider.invoke_llm_stream = model_stream
ctx = context()
conversation_id = (await service.create(ctx))['uuid']
stream = await assistant_turn(ap, ctx, TurnInput(conversation_id=conversation_id, revision=0, text='Hi'))
try:
while (await asyncio.wait_for(anext(stream), 2))['kind'] != 'text':
pass
task = service._runs[conversation_id]['task']
queue = service._runs[conversation_id]['queue']
assert queue.maxsize == 32
await stream.aclose()
assert not task.done()
assert _assistant_service(ap) is service
assert (await service.progress(ctx, conversation_id))['progress']['text'] == 'first'
release.set()
await asyncio.wait_for(task, 3)
completed = await service.get(ctx, conversation_id)
assert completed['status'] == 'ready'
assert completed['messages'][-1]['content'] == 'first' + 'x' * 100 + ' done'
assert queue.qsize() == 32
frames = [queue.get_nowait() for _ in range(queue.qsize())]
assert frames[-1]['kind'] == 'completed'
with pytest.raises(AssistantError, match='conversation_not_found'):
await assistant_turn(
ap, context(account='other'), TurnInput(conversation_id=conversation_id, revision=1, text='steal')
)
with pytest.raises(ValueError, match='Provide text or an approval decision'):
await assistant_turn(
ap, ctx, TurnInput(conversation_id=conversation_id, revision=1, text='both', approved=True)
)
finally:
release.set()
await stream.aclose()
for run in list(service._runs.values()):
await asyncio.wait_for(run['task'], 3)
@pytest.mark.asyncio
async def test_debug_provider_cancellation_wakes_subscriber_without_timeout():
finished = asyncio.Event()
async def cancelled(*args, **kwargs):
try:
raise asyncio.CancelledError()
finally:
finished.set()
ap, active = scoped_app(cancelled)
stream = await agent_debug(ap, context(), AgentDebugInput(agent_uuid='agent', payload={}))
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(anext(stream), 0.5)
assert finished.is_set()
assert active.get() is None
await stream.aclose()