Ctrl K
ComunidadGuías…

test/stream/test_timeout.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0

import asyncio
import threading
from collections.abc import AsyncGenerator
from contextlib import suppress
from typing import Annotated

import pytest
from fast_depends import Depends

from ag2 import Context, MemoryStream
from ag2.events import ModelMessage, ToolCallEvent


class WaitingHandler:
    def __init__(self) -> None:
        self.started = asyncio.Event()
        self.release = asyncio.Event()
        self.settled = asyncio.Event()

    async def handle(self, event: ModelMessage) -> ModelMessage:
        self.started.set()
        try:
            await self.release.wait()
            return event
        finally:
            self.settled.set()


class ThreadHandler:
    def __init__(self) -> None:
        self.started = threading.Event()
        self.release = threading.Event()
        self.settled = threading.Event()

    def handle(self, event: ModelMessage) -> None:
        self.started.set()
        try:
            self.release.wait()
        finally:
            self.settled.set()


class RecordingHandler:
    def __init__(self) -> None:
        self.events: list[ModelMessage] = []

    def handle(self, event: ModelMessage) -> None:
        self.events.append(event)


async def _resource(ctx: Context) -> AsyncGenerator[str]:
    try:
        if ctx.dependencies["phase"] == "setup":
            await ctx.dependencies["handler"].handle(ModelMessage("setup"))
        yield "resource"
    finally:
        try:
            if ctx.dependencies["phase"] == "cleanup":
                await ctx.dependencies["handler"].handle(ModelMessage("cleanup"))
        finally:
            ctx.dependencies["cleaned_up"].set()


async def _use_resource(event: ModelMessage, ctx: Context, resource: Annotated[str, Depends(_resource)]) -> None:
    if ctx.dependencies["phase"] == "handler":
        await ctx.dependencies["handler"].handle(event)


def _raise_timeout(event: ModelMessage) -> None:
    raise TimeoutError("upstream request timed out")


def _transform(event: ModelMessage) -> ModelMessage:
    return ModelMessage(event.content.upper())


@pytest.mark.asyncio
async def test_successful_timed_handlers_preserve_filtering_and_event_replacement() -> None:
    stream = MemoryStream()
    ctx = Context(stream)
    recorder = RecordingHandler()
    stream.where(ModelMessage).subscribe(_transform, interrupt=True, timeout=1, sync_to_thread=False)
    stream.where(ModelMessage).subscribe(recorder.handle, timeout=1, sync_to_thread=False)

    await ctx.send(ToolCallEvent(name="ignored"))
    await ctx.send(ModelMessage("first"))
    await ctx.send(ModelMessage("second"))

    assert recorder.events == [ModelMessage("FIRST"), ModelMessage("SECOND")]


@pytest.mark.asyncio
@pytest.mark.parametrize("registration", ["subscribe", "decorator", "scope"])
@pytest.mark.parametrize("filtered", [False, True])
@pytest.mark.parametrize("interrupt", [False, True])
async def test_timeout_through_subscription_helpers(registration: str, filtered: bool, interrupt: bool) -> None:
    stream = MemoryStream()
    target = stream.where(ModelMessage) if filtered else stream
    handler = WaitingHandler()
    recorder = RecordingHandler()
    ctx = Context(stream)

    if registration == "subscribe":
        sub_id = target.subscribe(handler.handle, interrupt=interrupt, timeout=0.02)
    elif registration == "decorator":
        sub_id = target.subscribe(interrupt=interrupt, timeout=0.02)(handler.handle)
    else:
        with target.sub_scope(handler.handle, interrupt=interrupt, timeout=0.02), pytest.raises(TimeoutError):
            await ctx.send(ModelMessage("blocked"))
        stream.subscribe(recorder.handle, sync_to_thread=False)
        await ctx.send(ModelMessage("after scope"))
        assert recorder.events == [ModelMessage("after scope")]
        assert handler.settled.is_set()
        return

    stream.subscribe(recorder.handle, sync_to_thread=False)
    with pytest.raises(TimeoutError):
        await ctx.send(ModelMessage("blocked"))
    assert recorder.events == []
    assert handler.started.is_set()
    assert handler.settled.is_set()

    target.unsubscribe(sub_id)
    await ctx.send(ModelMessage("after unsubscribe"))
    assert recorder.events == [ModelMessage("after unsubscribe")]


@pytest.mark.asyncio
@pytest.mark.parametrize("phase", ["setup", "handler", "cleanup"])
async def test_timeout_covers_dependencies_and_cleanup(phase: str) -> None:
    stream = MemoryStream()
    handler = WaitingHandler()
    cleaned_up = asyncio.Event()
    ctx = Context(stream, dependencies={"phase": phase, "handler": handler, "cleaned_up": cleaned_up})
    stream.subscribe(_use_resource, timeout=0.02)

    with pytest.raises(TimeoutError):
        await ctx.send(ModelMessage("blocked"))

    assert handler.started.is_set()
    assert handler.settled.is_set()
    assert cleaned_up.is_set()


@pytest.mark.asyncio
async def test_each_invocation_gets_its_own_timeout() -> None:
    stream = MemoryStream()
    ctx = Context(stream)
    handler = WaitingHandler()
    stream.subscribe(handler.handle, timeout=0.02)

    for _ in range(2):
        handler.settled.clear()
        with pytest.raises(TimeoutError):
            await ctx.send(ModelMessage("blocked"))
        assert handler.settled.is_set()

    handler.release.set()
    await ctx.send(ModelMessage("released"))


@pytest.mark.asyncio
@pytest.mark.parametrize("timeout", [None, 60])
async def test_caller_cancellation_propagates(timeout: float | None) -> None:
    stream = MemoryStream()
    ctx = Context(stream)
    handler = WaitingHandler()
    stream.subscribe(handler.handle, timeout=timeout)
    task = asyncio.create_task(ctx.send(ModelMessage("blocked")))
    try:
        await handler.started.wait()
    finally:
        task.cancel()
        with pytest.raises(asyncio.CancelledError):
            await task

    assert handler.settled.is_set()


@pytest.mark.asyncio
async def test_handler_raised_timeout_keeps_original_message() -> None:
    stream = MemoryStream()
    stream.subscribe(_raise_timeout, timeout=1)

    with pytest.raises(TimeoutError, match="^upstream request timed out$"):
        await Context(stream).send(ModelMessage("trigger"))


@pytest.mark.asyncio
async def test_sync_timeout_leaves_thread_running_until_released() -> None:
    stream = MemoryStream()
    handler = ThreadHandler()
    stream.subscribe(handler.handle, timeout=0.1)
    task = asyncio.create_task(Context(stream).send(ModelMessage("blocked")))
    try:
        assert await asyncio.to_thread(handler.started.wait, 5)
        with pytest.raises(TimeoutError):
            await task
        assert not handler.settled.is_set()
    finally:
        handler.release.set()
        with suppress(TimeoutError):
            await task
        assert await asyncio.to_thread(handler.settled.wait, 5)


@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), -float("inf")])
@pytest.mark.parametrize("filtered", [False, True])
def test_invalid_timeout_is_rejected(timeout: float, filtered: bool) -> None:
    stream = MemoryStream()
    target = stream.where(ModelMessage) if filtered else stream
    with pytest.raises(ValueError, match="finite number > 0 seconds or None"):
        target.subscribe(_raise_timeout, timeout=timeout)