mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
159 lines
6.0 KiB
Python
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()
|