Ctrl K
ComunidadGuías…

test/middleware/test_statusbar.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
from collections.abc import AsyncGenerator, Sequence
from dataclasses import dataclass, field
from typing import Annotated, Any

import pytest

from ag2 import Agent, Context, DataInput, Depends, Inject, MemoryStream, TextInput, Variable
from ag2.events import (
    BaseEvent,
    Input,
    ModelRequest,
    ModelResponse,
    ToolCallEvent,
    ToolResultsEvent,
    is_conversational,
)
from ag2.middleware import (
    BaseMiddleware,
    HistoryLimiter,
    LLMCall,
    MiddlewareDescription,
    RetryMiddleware,
    TokenLimiter,
    statusbar_middleware,
)
from ag2.plugin import Plugin
from ag2.testing import TestConfig, TrackingConfig


@statusbar_middleware
def current_status(context: Context) -> TextInput:
    return TextInput(f"step:{context.variables.get('step', 0)}")


def advance(context: Context) -> str:
    context.variables["step"] = context.variables.get("step", 0) + 1
    return "advanced"


def request_texts(events: Sequence[BaseEvent]) -> list[str]:
    return [
        part.content
        for event in events
        if isinstance(event, ModelRequest)
        for part in event.parts
        if isinstance(part, TextInput)
    ]


def status_parts(events: Sequence[BaseEvent]) -> list[Input]:
    status = events[-1]
    assert isinstance(status, ModelRequest)
    assert status.origin == "service"
    assert not is_conversational(status)
    return status.parts


@pytest.mark.asyncio
async def test_dynamic_status_refreshes_after_tools_without_entering_history() -> None:
    config = TrackingConfig(TestConfig(ToolCallEvent("advance"), "done", "continued"))
    agent = Agent("status", config=config, middleware=[current_status], tools=[advance], prompt="static")

    reply = await agent.ask("start")
    await reply.ask("continue")

    assert [request_texts(call.messages) for call in config.calls] == [
        ["start", "step:0"],
        ["start", "step:1"],
        ["start", "continue", "step:1"],
    ]
    assert request_texts(await reply.context.stream.history.get_events()) == ["start", "continue"]
    assert [call.prompt for call in config.calls] == [("static",)] * 3


def source(context: Context, label: Annotated[str, Inject()]) -> str:
    return f"{label}:{context.variables.get('step', 0)}"


def overridden_source(label: Annotated[str, Inject()]) -> str:
    return f"override:{label}"


@statusbar_middleware(persist=False)
async def injected_status(
    context: Context,
    label: Annotated[str, Inject()],
    step: Annotated[int, Variable(default=0)],
    value: Annotated[str, Depends(source)],
) -> Sequence[Input]:
    assert context.dependencies["label"] == label
    return [TextInput(f"{label}:{step}"), DataInput({"value": value})]


@pytest.mark.asyncio
async def test_async_status_resolves_injected_variables_and_composed_dependencies_afresh() -> None:
    config = TrackingConfig(TestConfig(ToolCallEvent("advance"), "done"))
    agent = Agent("status", config=config, middleware=[injected_status], tools=[advance])

    await agent.ask("start", dependencies={"label": "job"})

    assert [status_parts(call.messages) for call in config.calls] == [
        [TextInput("job:0"), DataInput({"value": "job:0"})],
        [TextInput("job:1"), DataInput({"value": "job:1"})],
    ]


@pytest.mark.asyncio
async def test_status_uses_agent_dependency_overrides() -> None:
    config = TrackingConfig(TestConfig("done"))
    agent = Agent("status", config=config, middleware=[injected_status])
    agent.dependency_provider.override(source, overridden_source)

    await agent.ask("start", dependencies={"label": "job"})

    assert status_parts(config.calls[0].messages) == [TextInput("job:0"), DataInput({"value": "override:job"})]


@statusbar_middleware
def supplied_status(value: Annotated[Any, Inject()]) -> Any:
    return value


@pytest.mark.asyncio
@pytest.mark.parametrize("value", ["text", {"text": "value"}, [TextInput("valid"), "invalid"], b"bytes", 12])
async def test_invalid_status_fails_before_model_or_history_insertion(value: Any) -> None:
    config = TrackingConfig(TestConfig("unreachable"))
    agent = Agent("status", config=config, middleware=[supplied_status])
    stream = MemoryStream()

    with pytest.raises(TypeError, match="Input, Sequence"):
        await agent.ask("start", dependencies={"value": value}, stream=stream)

    assert config.calls == []
    assert request_texts(await stream.history.get_events()) == ["start"]


@pytest.mark.asyncio
async def test_generator_status_is_rejected_without_consuming_it() -> None:
    config = TrackingConfig(TestConfig("unreachable"))
    agent = Agent("status", config=config, middleware=[supplied_status])
    value = (TextInput(str(i)) for i in range(2))

    with pytest.raises(TypeError, match="Input, Sequence"):
        await agent.ask("start", dependencies={"value": value})

    assert next(value) == TextInput("0")
    assert config.calls == []


@pytest.mark.asyncio
@pytest.mark.parametrize("value", [None, [], ()])
async def test_empty_status_leaves_model_input_unchanged(value: Any) -> None:
    config = TrackingConfig(TestConfig("done"))
    agent = Agent("status", config=config, middleware=[supplied_status])

    await agent.ask("start", dependencies={"value": value})

    assert config.calls[0].messages == (ModelRequest([TextInput("start")]),)


@statusbar_middleware
def first_status() -> TextInput:
    return TextInput("first")


@statusbar_middleware
def second_status() -> TextInput:
    return TextInput("second")


@pytest.mark.asyncio
@pytest.mark.parametrize("via_plugin", [False, True])
async def test_invocation_registration_and_plugin_scope(via_plugin: bool) -> None:
    config = TrackingConfig(TestConfig("one", "two", "three"))
    agent = Agent("status", config=config, middleware=[first_status])
    options = {"plugins": [Plugin(middleware=[second_status])]} if via_plugin else {"middleware": [second_status]}

    reply = await agent.ask("start", **options)
    reply = await reply.ask("continue")
    await reply.ask("again", **options)

    assert [request_texts(call.messages) for call in config.calls] == [
        ["start", "first", "second"],
        ["start", "continue", "first"],
        ["start", "continue", "again", "first", "second"],
    ]


@pytest.mark.asyncio
async def test_status_is_available_during_observable_run() -> None:
    config = TrackingConfig(TestConfig("done"))
    agent = Agent("status", config=config, middleware=[first_status])

    async with agent.run("start") as run:
        seen = []
        with run.stream.join() as events:
            run.start()
            async for event in events:
                seen.append(event)
                if isinstance(event, ModelResponse):
                    break
        reply = await run.result()

    assert reply.body == "done"
    assert not any(isinstance(event, ModelRequest) and TextInput("first") in event.parts for event in seen)
    assert request_texts(config.calls[0].messages) == ["start", "first"]


@statusbar_middleware
def counting_status(context: Context) -> TextInput:
    count = context.variables.get("status_count", 0) + 1
    context.variables["status_count"] = count
    return TextInput(f"count:{count}")


@pytest.mark.asyncio
async def test_status_outside_retry_reuses_snapshot_but_refreshes_after_tool_round() -> None:
    config = TrackingConfig(TestConfig(TimeoutError("retry"), ToolCallEvent("advance"), "done"))
    agent = Agent(
        "status",
        config=config,
        tools=[advance],
        middleware=[counting_status, RetryMiddleware(max_retries=1, retry_on=(TimeoutError,))],
    )

    await agent.ask("start")

    assert [request_texts(call.messages) for call in config.calls] == [
        ["start", "count:1"],
        ["start", "count:1"],
        ["start", "count:2"],
    ]


@pytest.mark.asyncio
async def test_status_inside_retry_follows_normal_wrapper_order() -> None:
    config = TrackingConfig(TestConfig(TimeoutError("retry"), "done"))
    agent = Agent(
        "status",
        config=config,
        middleware=[RetryMiddleware(max_retries=1, retry_on=(TimeoutError,)), counting_status],
    )

    await agent.ask("start")

    assert [request_texts(call.messages) for call in config.calls] == [["start", "count:1"], ["start", "count:2"]]


@pytest.mark.asyncio
async def test_concurrent_conversations_on_one_agent_have_independent_status_state() -> None:
    config = TrackingConfig(TestConfig("done", "done"))
    agent = Agent("status", config=config, middleware=[counting_status])

    await asyncio.gather(agent.ask("one"), agent.ask("two"))

    assert sorted(request_texts(call.messages) for call in config.calls) == [
        ["one", "count:1"],
        ["two", "count:1"],
    ]


@dataclass
class Resource:
    events: list[str] = field(default_factory=list)
    opened: bool = False
    started: asyncio.Event = field(default_factory=asyncio.Event)
    release: asyncio.Event = field(default_factory=asyncio.Event)


async def open_resource(resource: Annotated[Resource, Inject()]) -> AsyncGenerator[Resource]:
    resource.opened = True
    resource.events.append("enter")
    try:
        yield resource
    finally:
        resource.events.append("exit")
        resource.opened = False


@statusbar_middleware
async def resource_status(resource: Annotated[Resource, Depends(open_resource)]) -> TextInput:
    resource.events.append("callback")
    return TextInput("resource ready")


class ResourceModelMiddleware(BaseMiddleware):
    async def on_llm_call(self, call_next: LLMCall, events: Sequence[BaseEvent], context: Context) -> ModelResponse:
        resource = context.dependencies["resource"]
        assert resource.opened
        resource.events.append("model")
        if context.variables.get("wait"):
            resource.started.set()
            await resource.release.wait()
        return await call_next(events, context)


@pytest.mark.asyncio
@pytest.mark.parametrize("fail_model", [False, True])
async def test_generator_dependency_stays_open_through_model_attempts(fail_model: bool) -> None:
    config = TrackingConfig(TestConfig(ValueError("provider") if fail_model else "done"))
    agent = Agent("status", config=config, middleware=[resource_status, ResourceModelMiddleware])
    resource = Resource()

    if fail_model:
        with pytest.raises(ValueError, match="provider"):
            await agent.ask("start", dependencies={"resource": resource})
    else:
        await agent.ask("start", dependencies={"resource": resource})

    assert resource.events == ["enter", "callback", "model", "exit"]
    assert not resource.opened


@statusbar_middleware
async def broken_resource_status(resource: Annotated[Resource, Depends(open_resource)]) -> TextInput:
    raise ValueError("callback")


@statusbar_middleware
async def invalid_resource_status(resource: Annotated[Resource, Depends(open_resource)]) -> Any:
    return "invalid"


async def broken_dependency(resource: Annotated[Resource, Depends(open_resource)]) -> None:
    raise ValueError("dependency")


@statusbar_middleware
async def broken_dependency_status(value: Annotated[None, Depends(broken_dependency)]) -> None:
    return None


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("middleware", "error"),
    [
        (broken_resource_status, ValueError),
        (invalid_resource_status, TypeError),
        (broken_dependency_status, ValueError),
    ],
)
async def test_resource_cleanup_on_callback_validation_or_dependency_failure(middleware, error) -> None:
    config = TrackingConfig(TestConfig("unreachable"))
    agent = Agent("status", config=config, middleware=[middleware])
    resource = Resource()

    with pytest.raises(error):
        await agent.ask("start", dependencies={"resource": resource})

    assert resource.events == ["enter", "exit"]
    assert not resource.opened
    assert config.calls == []


@pytest.mark.asyncio
async def test_cancellation_closes_status_dependency_and_propagates() -> None:
    config = TrackingConfig(TestConfig("unreachable"))
    agent = Agent("status", config=config, middleware=[resource_status, ResourceModelMiddleware])
    resource = Resource()
    task = asyncio.create_task(agent.ask("start", dependencies={"resource": resource}, variables={"wait": True}))
    await resource.started.wait()

    task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await task

    assert resource.events == ["enter", "callback", "model", "exit"]
    assert not resource.opened
    assert config.calls == []


def test_status_description_has_callback_identity_without_dependency_values() -> None:
    agent = Agent("status", middleware=[injected_status], dependencies={"label": "secret"})

    assert agent.middleware[0].description == MiddlewareDescription(
        kind="statusbar_middleware",
        config={"persist": False, "callback": f"{__name__}.injected_status"},
    )


@pytest.mark.asyncio
async def test_dependency_stays_open_through_inner_retries_and_closes_once() -> None:
    config = TrackingConfig(TestConfig(TimeoutError("retry"), "done"))
    agent = Agent(
        "status",
        config=config,
        middleware=[resource_status, RetryMiddleware(max_retries=1, retry_on=(TimeoutError,)), ResourceModelMiddleware],
    )
    resource = Resource()

    await agent.ask("start", dependencies={"resource": resource})

    assert resource.events == ["enter", "callback", "model", "model", "exit"]
    assert not resource.opened


def enqueue_update(context: Context) -> str:
    context.enqueue("background update")
    return "queued"


@statusbar_middleware
async def last_input_status(context: Context) -> TextInput:
    events = await context.stream.history.get_events()
    requests = [event for event in events if isinstance(event, ModelRequest)]
    return TextInput(f"latest:{requests[-1].parts[0].content}")


@pytest.mark.asyncio
async def test_status_observes_pending_input_drained_before_next_model_call() -> None:
    config = TrackingConfig(TestConfig(ToolCallEvent("enqueue_update"), "done"))
    agent = Agent("status", config=config, middleware=[last_input_status], tools=[enqueue_update])

    await agent.ask("start")

    assert [request_texts(call.messages) for call in config.calls] == [
        ["start", "latest:start"],
        ["start", "background update", "latest:background update"],
    ]


class MutateStatusMiddleware(BaseMiddleware):
    async def on_llm_call(self, call_next: LLMCall, events: Sequence[BaseEvent], context: Context) -> ModelResponse:
        status = events[-1]
        assert isinstance(status, ModelRequest)
        [part] = status.parts
        assert isinstance(part, DataInput)
        part.data["steps"].append("changed by downstream")
        return await call_next(events, context)


@pytest.mark.asyncio
async def test_downstream_request_changes_do_not_mutate_callback_source_payload() -> None:
    config = TrackingConfig(TestConfig("done"))
    value = DataInput({"steps": ["queued"]})
    agent = Agent("status", config=config, middleware=[supplied_status, MutateStatusMiddleware])

    reply = await agent.ask("start", dependencies={"value": value})

    assert value == DataInput({"steps": ["queued"]})
    assert status_parts(config.calls[0].messages) == [DataInput({"steps": ["queued", "changed by downstream"]})]
    assert request_texts(await reply.context.stream.history.get_events()) == ["start"]


class CallableStatus:
    def __call__(self) -> TextInput:
        return TextInput("instance")

    def __repr__(self) -> str:
        return "secret"


@pytest.mark.asyncio
async def test_callable_instance_uses_type_identity_in_description() -> None:
    middleware = statusbar_middleware(CallableStatus())
    config = TrackingConfig(TestConfig("done"))
    agent = Agent("status", config=config, middleware=[middleware])

    await agent.ask("start")

    assert agent.middleware[0].description == MiddlewareDescription(
        kind="statusbar_middleware",
        config={"persist": False, "callback": f"{__name__}.CallableStatus"},
    )
    assert request_texts(config.calls[0].messages) == ["start", "instance"]


@pytest.mark.asyncio
@pytest.mark.parametrize("persist", [False, True])
@pytest.mark.parametrize("limiter", [HistoryLimiter(max_events=3), TokenLimiter(max_tokens=1)])
async def test_status_outside_history_limiter_keeps_the_tool_round_it_follows(persist: bool, limiter: Any) -> None:
    config = TrackingConfig(TestConfig(ToolCallEvent("advance"), "done"))
    status = statusbar_middleware(persist=persist)(lambda: TextInput("status"))
    agent = Agent("status", config=config, middleware=[status, limiter], tools=[advance])

    await agent.ask("start")

    after_tool = config.calls[1].messages
    assert any(isinstance(event, ToolResultsEvent) for event in after_tool)
    assert request_texts(after_tool)[-1] == "status"