Ctrl K
ComunidadGuías…

test/test_watch.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 datetime

import pytest

from ag2 import Context
from ag2.events import ModelMessage, ToolCallEvent
from ag2.stream import MemoryStream
from ag2.watch import (
    AllOf,
    AnyOf,
    CadenceWatch,
    CronWatch,
    DelayWatch,
    EventWatch,
    IntervalWatch,
    Sequence,
)


class TestEventWatch:
    @pytest.mark.asyncio
    async def test_fires_on_matching_event(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.extend(events)

        watch = EventWatch(ToolCallEvent)
        watch.arm(stream, callback)
        assert watch.is_armed

        event = ToolCallEvent(name="search", arguments="{}")
        await ctx.send(event)
        assert len(received) == 1
        assert received[0] is event

    @pytest.mark.asyncio
    async def test_does_not_fire_on_non_matching(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.extend(events)

        watch = EventWatch(ToolCallEvent)
        watch.arm(stream, callback)

        await ctx.send(ModelMessage(content="hello"))
        assert len(received) == 0

    @pytest.mark.asyncio
    async def test_condition_filter(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.extend(events)

        watch = EventWatch(ToolCallEvent.name == "search")
        watch.arm(stream, callback)

        await ctx.send(ToolCallEvent(name="search", arguments="{}"))
        await ctx.send(ToolCallEvent(name="other", arguments="{}"))
        assert len(received) == 1

    @pytest.mark.asyncio
    async def test_disarm_stops_firing(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.extend(events)

        watch = EventWatch(ToolCallEvent)
        watch.arm(stream, callback)
        watch.disarm()
        assert not watch.is_armed

        await ctx.send(ToolCallEvent(name="search", arguments="{}"))
        assert len(received) == 0


class TestCadenceWatch:
    @pytest.mark.asyncio
    async def test_fires_after_n_events(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=3, condition=ToolCallEvent)
        watch.arm(stream, callback)

        for i in range(5):
            await ctx.send(ToolCallEvent(name=f"t{i}", arguments="{}"))

        # 5 events, batch size 3 -> 1 batch of 3, 2 remaining
        assert len(batches) == 1
        assert len(batches[0]) == 3

    @pytest.mark.asyncio
    async def test_multiple_batches(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=2)
        watch.arm(stream, callback)

        for i in range(4):
            await ctx.send(ModelMessage(content=f"m{i}"))

        assert len(batches) == 2

    @pytest.mark.asyncio
    async def test_disarm_clears_buffer(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=5)
        watch.arm(stream, callback)

        await ctx.send(ModelMessage(content="m1"))
        watch.disarm()
        assert len(batches) == 0

    @pytest.mark.asyncio
    async def test_fires_on_timeout(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(max_wait=0.1, condition=ToolCallEvent)
        watch.arm(stream, callback)

        await ctx.send(ToolCallEvent(name="t1", arguments="{}"))
        await ctx.send(ToolCallEvent(name="t2", arguments="{}"))

        await asyncio.sleep(0.2)

        assert len(batches) == 1
        assert len(batches[0]) == 2

    @pytest.mark.asyncio
    async def test_n_wins_when_reached_before_timeout(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=2, max_wait=1.0)
        watch.arm(stream, callback)

        await ctx.send(ModelMessage(content="m1"))
        await ctx.send(ModelMessage(content="m2"))

        # Count trigger fires immediately; no need to wait for timeout
        assert len(batches) == 1
        assert len(batches[0]) == 2

    @pytest.mark.asyncio
    async def test_timeout_wins_when_n_not_reached(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=10, max_wait=0.1)
        watch.arm(stream, callback)

        await ctx.send(ModelMessage(content="m1"))
        await asyncio.sleep(0.2)

        assert len(batches) == 1
        assert len(batches[0]) == 1

    def test_requires_at_least_one_trigger(self) -> None:
        with pytest.raises(ValueError, match="at least one of 'n' or 'max_wait'"):
            CadenceWatch()

    def test_rejects_non_positive_n(self) -> None:
        with pytest.raises(ValueError, match="'n' must be positive"):
            CadenceWatch(n=0)

    def test_rejects_non_positive_max_wait(self) -> None:
        with pytest.raises(ValueError, match="'max_wait' must be positive"):
            CadenceWatch(max_wait=0)

    @pytest.mark.asyncio
    async def test_count_trigger_cancels_pending_timer(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=2, max_wait=0.2)
        watch.arm(stream, callback)

        # Count trigger fires
        await ctx.send(ModelMessage(content="m1"))
        await ctx.send(ModelMessage(content="m2"))
        assert len(batches) == 1

        # Wait past max_wait; cancelled timer must not fire a phantom batch
        await asyncio.sleep(0.3)
        assert len(batches) == 1

    @pytest.mark.asyncio
    async def test_timer_restarts_after_count_flush(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=2, max_wait=0.1)
        watch.arm(stream, callback)

        # Count-trigger the first batch
        await ctx.send(ModelMessage(content="m1"))
        await ctx.send(ModelMessage(content="m2"))
        assert len(batches) == 1

        # A single follow-up event should start a fresh timer and flush on timeout
        await ctx.send(ModelMessage(content="m3"))
        await asyncio.sleep(0.2)

        assert len(batches) == 2
        assert len(batches[1]) == 1

    @pytest.mark.asyncio
    async def test_events_during_slow_callback_are_not_stranded(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        delivered: list[str] = []
        callback_sleep = 0.15
        max_wait = 0.05

        async def slow_callback(events, _ctx):
            await asyncio.sleep(callback_sleep)
            delivered.extend(e.content for e in events)

        watch = CadenceWatch(n=5, max_wait=max_wait)
        watch.arm(stream, slow_callback)

        # Wave 1: count-trigger fires batch 1 (callback now awaiting).
        for i in range(1, 6):
            await ctx.send(ModelMessage(content=f"m{i}"))

        # Wave 2: while batch 1's callback is in flight, queue 3 events.
        # max_wait elapses, _wait_and_fire fires batch 2 (also slow).
        await asyncio.sleep(0.01)
        for i in range(6, 9):
            await ctx.send(ModelMessage(content=f"m{i}"))
        await asyncio.sleep(max_wait + 0.02)

        # Wave 3: lands while batch 2's callback is awaiting. Pre-fix, the
        # max_wait timer task is alive (in callback) so no fresh timer is
        # scheduled and these events sit in the buffer forever.
        for i in range(9, 12):
            await ctx.send(ModelMessage(content=f"m{i}"))

        # Wait for all in-flight callbacks plus one more max_wait cycle.
        await asyncio.sleep(2 * callback_sleep + max_wait + 0.1)

        assert set(delivered) == {f"m{i}" for i in range(1, 12)}

    @pytest.mark.asyncio
    async def test_count_fires_after_timer_flush(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        batches: list = []

        async def callback(events, _ctx):
            batches.append(events)

        watch = CadenceWatch(n=3, max_wait=0.1)
        watch.arm(stream, callback)

        # Timer-trigger the first batch with a single event
        await ctx.send(ModelMessage(content="m1"))
        await asyncio.sleep(0.2)
        assert len(batches) == 1
        assert len(batches[0]) == 1

        # Count trigger must still work on the next batch
        for i in range(3):
            await ctx.send(ModelMessage(content=f"x{i}"))

        assert len(batches) == 2
        assert len(batches[1]) == 3


class TestIntervalWatch:
    @pytest.mark.asyncio
    async def test_fires_periodically(self) -> None:
        stream = MemoryStream()
        call_count = 0

        async def callback(events, _ctx):
            nonlocal call_count
            call_count += 1

        watch = IntervalWatch(0.05)
        watch.arm(stream, callback)
        assert watch.is_armed

        await asyncio.sleep(0.18)
        watch.disarm()
        assert not watch.is_armed
        # Should have fired ~3 times in 0.18s at 0.05s interval
        assert call_count >= 2

    @pytest.mark.asyncio
    async def test_disarm_stops_timer(self) -> None:
        stream = MemoryStream()
        call_count = 0

        async def callback(events, _ctx):
            nonlocal call_count
            call_count += 1

        watch = IntervalWatch(0.05)
        watch.arm(stream, callback)
        watch.disarm()

        await asyncio.sleep(0.15)
        assert call_count == 0


class TestDelayWatch:
    @pytest.mark.asyncio
    async def test_fires_once_after_delay(self) -> None:
        stream = MemoryStream()
        call_count = 0

        async def callback(events, _ctx):
            nonlocal call_count
            call_count += 1

        watch = DelayWatch(0.05)
        watch.arm(stream, callback)

        await asyncio.sleep(0.15)
        assert call_count == 1
        assert not watch.is_armed  # Auto-disarmed

    @pytest.mark.asyncio
    async def test_disarm_before_fire(self) -> None:
        stream = MemoryStream()
        call_count = 0

        async def callback(events, _ctx):
            nonlocal call_count
            call_count += 1

        watch = DelayWatch(0.1)
        watch.arm(stream, callback)
        watch.disarm()

        await asyncio.sleep(0.2)
        assert call_count == 0


class TestAllOf:
    @pytest.mark.asyncio
    async def test_fires_when_all_sub_watches_fired(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = AllOf(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        # Fire only one — should not trigger
        await ctx.send(ToolCallEvent(name="t", arguments="{}"))
        assert len(received) == 0

        # Fire the other — now both have fired
        await ctx.send(ModelMessage(content="m"))
        assert len(received) == 1

    @pytest.mark.asyncio
    async def test_collects_events_from_all_sub_watches(self) -> None:
        """AllOf should include events from ALL sub-watches in callback, not just the last."""
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = AllOf(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        tool_event = ToolCallEvent(name="search", arguments="{}")
        msg_event = ModelMessage(content="hello")

        await ctx.send(tool_event)
        await ctx.send(msg_event)

        assert len(received) == 1
        combined = received[0]
        # Should contain events from both sub-watches
        assert len(combined) == 2
        assert tool_event in combined
        assert msg_event in combined

    @pytest.mark.asyncio
    async def test_resets_after_firing(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = AllOf(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        # First cycle
        await ctx.send(ToolCallEvent(name="t", arguments="{}"))
        await ctx.send(ModelMessage(content="m"))
        assert len(received) == 1

        # Second cycle
        await ctx.send(ToolCallEvent(name="t2", arguments="{}"))
        await ctx.send(ModelMessage(content="m2"))
        assert len(received) == 2


class TestAnyOf:
    @pytest.mark.asyncio
    async def test_fires_on_either_watch(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = AnyOf(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        await ctx.send(ToolCallEvent(name="t", arguments="{}"))
        assert len(received) == 1

        await ctx.send(ModelMessage(content="m"))
        assert len(received) == 2


class TestSequence:
    @pytest.mark.asyncio
    async def test_fires_in_order(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = Sequence(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        # Wrong order — message first, then tool call → should not fire
        await ctx.send(ModelMessage(content="m"))
        assert len(received) == 0

        # Right order — tool call (matches first watch)
        await ctx.send(ToolCallEvent(name="t", arguments="{}"))
        assert len(received) == 0  # Only first step done

        # Second step — message
        await ctx.send(ModelMessage(content="m2"))
        assert len(received) == 1  # Sequence complete

    @pytest.mark.asyncio
    async def test_resets_after_completion(self) -> None:
        stream = MemoryStream()
        ctx = Context(stream=stream)
        received: list = []

        async def callback(events, _ctx):
            received.append(events)

        w = Sequence(
            EventWatch(ToolCallEvent),
            EventWatch(ModelMessage),
        )
        w.arm(stream, callback)

        # Complete first sequence
        await ctx.send(ToolCallEvent(name="t1", arguments="{}"))
        await ctx.send(ModelMessage(content="m1"))
        assert len(received) == 1

        # Complete second sequence
        await ctx.send(ToolCallEvent(name="t2", arguments="{}"))
        await ctx.send(ModelMessage(content="m2"))
        assert len(received) == 2


class TestCronWatchExpressions:
    def test_range_expression(self) -> None:
        cron = CronWatch("1-5 * * * *")
        now = datetime.datetime(2026, 3, 22, 10, 0, 0)
        next_fire = cron._next_fire_time(now)
        assert next_fire.minute in {1, 2, 3, 4, 5}

    def test_list_expression(self) -> None:
        cron = CronWatch("0,15,30,45 * * * *")
        now = datetime.datetime(2026, 3, 22, 10, 0, 0)
        next_fire = cron._next_fire_time(now)
        assert next_fire.minute in {0, 15, 30, 45}

    def test_step_with_range(self) -> None:
        cron = CronWatch("*/10 * * * *")
        now = datetime.datetime(2026, 3, 22, 10, 0, 0)
        next_fire = cron._next_fire_time(now)
        assert next_fire.minute in {0, 10, 20, 30, 40, 50}

    def test_specific_hour_and_minute(self) -> None:
        cron = CronWatch("30 14 * * *")
        now = datetime.datetime(2026, 3, 22, 10, 0, 0)
        next_fire = cron._next_fire_time(now)
        assert next_fire.hour == 14
        assert next_fire.minute == 30

    def test_invalid_field_count_raises(self) -> None:
        cron = CronWatch("* * *")
        with pytest.raises(ValueError, match="5 fields"):
            cron._next_fire_time(datetime.datetime.now())

    def test_step_five_minutes(self) -> None:
        cron = CronWatch("*/5 * * * *")
        now = datetime.datetime(2026, 3, 21, 10, 3, 0)
        nxt = cron._next_fire_time(now)
        assert nxt.minute == 5
        assert nxt.hour == 10

    def test_day_of_week_name(self) -> None:
        cron = CronWatch("0 9 * * MON")
        # 2026-03-21 is a Saturday; next Monday is 2026-03-23
        now = datetime.datetime(2026, 3, 21, 10, 0, 0)
        nxt = cron._next_fire_time(now)
        assert nxt.weekday() == 0
        assert nxt.hour == 9
        assert nxt.minute == 0

    def test_invalid_expression_raises(self) -> None:
        cron = CronWatch("bad")
        with pytest.raises(ValueError, match="Invalid cron"):
            cron._next_fire_time(datetime.datetime.now())

    def test_numeric_dow_sunday_zero(self) -> None:
        cron = CronWatch("0 9 * * 0")
        # 2026-03-21 is a Saturday; next Sunday is 2026-03-22
        now = datetime.datetime(2026, 3, 21, 10, 0, 0)
        nxt = cron._next_fire_time(now)
        assert nxt.isoweekday() % 7 == 0
        assert nxt.day == 22
        assert nxt.hour == 9

    def test_numeric_dow_saturday_six(self) -> None:
        cron = CronWatch("0 9 * * 6")
        # 2026-03-21 (Saturday) at 10:00 — past 9:00; next Saturday is 2026-03-28
        now = datetime.datetime(2026, 3, 21, 10, 0, 0)
        nxt = cron._next_fire_time(now)
        assert nxt.isoweekday() % 7 == 6
        assert nxt.day == 28
        assert nxt.hour == 9

    def test_numeric_dow_seven_is_sunday_alias(self) -> None:
        watch_seven = CronWatch("0 9 * * 7")
        watch_zero = CronWatch("0 9 * * 0")
        now = datetime.datetime(2026, 3, 21, 10, 0, 0)
        assert watch_seven._next_fire_time(now) == watch_zero._next_fire_time(now)