# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 """Expectation evaluator/handler boundary cases — exact-deadline timing, zero-timeout edge, handler exception isolation, position-based dedup. The existing ``test_expectations.py`` covers the happy / sad paths. This file targets the boundary conditions the docstrings imply but don't explicitly exercise: * All three evaluators use ``elapsed < seconds`` (or ``>=`` for ``reply_within``) — confirm the exact ``elapsed == seconds`` threshold fires. * ``seconds=0`` should fire on the first tick (degenerate but worth proving deterministic). * A handler that raises must not stop the sweeper — other expectations on the same tick keep firing, the violation is still marked as fired (no infinite re-fire), and the audit log records what's expected. * The fired-set key is ``(idx, name, violator)`` — two expectations with the same ``name`` but different ``on_violation`` handlers must both fire (the position-based key disambiguates). * Unknown evaluator name → silently skipped (no crash). * Unknown handler name → silently skipped (no crash). """ from datetime import datetime import pytest from ag2.knowledge import MemoryKnowledgeStore from ag2.network import ( EV_TEXT, Envelope, Hub, ) from ag2.network.adapters.base import AdapterResult from ag2.network.adapters.conversation import ConversationAdapter from ag2.network.channel import ( ChannelManifest, ChannelMetadata, ChannelState, Expectation, Participant, ParticipantRole, ParticipantSchema, ) from ag2.network.hub import ( AUDIT_KIND_EXPECTATION_VIOLATED, AcksWithinEvaluator, AuditLog, ExpectationContext, MaxSilenceEvaluator, ReplyWithinEvaluator, ) from ag2.network.views.builtin import FullTranscript from ._helpers import _MockClock class _NoOpAdapter: """Drives expectation tests with custom manifests. The expectation sweeper only reads ``manifest.expectations`` and walks ``_active_channels`` — it never calls ``validate_send`` / ``fold`` / ``on_accepted``. This stand-in covers the adapter surface without exercising any choreography. """ def __init__(self, manifest: ChannelManifest) -> None: self.manifest = manifest def initial_state(self, _meta: ChannelMetadata) -> dict: return {} def fold(self, _envelope: Envelope, state: dict) -> dict: return state def validate_create(self, _meta: ChannelMetadata) -> None: return def validate_send(self, _meta: ChannelMetadata, _envelope: Envelope, _state: dict) -> None: return def on_accepted(self, _meta: ChannelMetadata, _envelope: Envelope, _state: dict) -> AdapterResult: return AdapterResult() def default_view_policy(self, _meta: ChannelMetadata, _participant_id: str) -> FullTranscript: return FullTranscript() def _conv_meta( *, state: ChannelState = ChannelState.PENDING, created_at: str = "2026-01-01T00:00:00+00:00", pending_acks: list[str] | None = None, ) -> ChannelMetadata: manifest = ConversationAdapter().manifest return ChannelMetadata( channel_id="s1", manifest=manifest, creator_id="alice", participants=[ Participant(agent_id="alice", role=ParticipantRole.INITIATOR, order=0), Participant(agent_id="bob", role=ParticipantRole.RESPONDENT, order=1), ], state=state, created_at=created_at, pending_acks=list(pending_acks or []), ) def _ctx(meta: ChannelMetadata, *, now: str, wal: list[Envelope] | None = None) -> ExpectationContext: now_dt = datetime.fromisoformat(now) return ExpectationContext( metadata=meta, state=None, wal=wal or [], now_iso=now, now_seconds=now_dt.timestamp(), ) def _inject_pending_channel( hub: Hub, *, channel_id: str, adapter_key: tuple[str, int], clock: _MockClock, pending_acks: tuple[str, ...] = ("bob",), ) -> ChannelMetadata: """Inject a PENDING channel metadata directly into the hub caches. ``create_channel`` would block on ``invite_ack_timeout`` waiting for acks that never arrive; these tests need the channel to stay PENDING long enough for the sweeper to evaluate. """ meta = ChannelMetadata( channel_id=channel_id, manifest=hub._adapters[adapter_key].manifest, creator_id="alice", participants=[ Participant(agent_id="alice", role=ParticipantRole.INITIATOR, order=0, joined_at=clock()), Participant(agent_id="bob", role=ParticipantRole.PARTICIPANT, order=1, joined_at=clock()), ], state=ChannelState.PENDING, created_at=clock(), pending_acks=list(pending_acks), ) hub._channels[channel_id] = meta hub._active_channels[channel_id] = meta hub._adapter_states[channel_id] = {} return meta class TestEvaluatorExactBoundary: """At ``elapsed == seconds``, the evaluator should fire (not be silent).""" def test_acks_within_fires_at_exact_threshold(self) -> None: meta = _conv_meta(state=ChannelState.PENDING, pending_acks=["bob"]) evaluator = AcksWithinEvaluator() expectation = Expectation(name="acks_within", on_violation="audit", params={"seconds": 30}) # 30s elapsed — equals threshold. violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:00:30+00:00")) assert violation is not None assert violation.violator_ids == ["bob"] def test_acks_within_silent_just_under_threshold(self) -> None: meta = _conv_meta(state=ChannelState.PENDING, pending_acks=["bob"]) evaluator = AcksWithinEvaluator() expectation = Expectation(name="acks_within", on_violation="audit", params={"seconds": 30}) # 29.999s — under threshold by a hair. violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:00:29.999000+00:00")) assert violation is None def test_max_silence_fires_at_exact_threshold(self) -> None: meta = _conv_meta(state=ChannelState.ACTIVE, created_at="2026-01-01T00:00:00+00:00") evaluator = MaxSilenceEvaluator() expectation = Expectation(name="max_silence", on_violation="audit", params={"seconds": 60}) violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:01:00+00:00")) assert violation is not None def test_reply_within_fires_at_exact_threshold(self) -> None: meta = _conv_meta(state=ChannelState.ACTIVE, created_at="2026-01-01T00:00:00+00:00") wal = [ Envelope( envelope_id="e1", channel_id="s1", sender_id="alice", audience=["bob"], event_type=EV_TEXT, event_data={"text": "hi"}, created_at="2026-01-01T00:00:00+00:00", ) ] evaluator = ReplyWithinEvaluator() expectation = Expectation(name="reply_within", on_violation="audit", params={"seconds": 60}) # exactly 60s elapsed — should fire (>= boundary) violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:01:00+00:00", wal=wal)) assert violation is not None assert violation.violator_ids == ["bob"] class TestEvaluatorZeroTimeout: """``seconds=0`` is degenerate but should fire deterministically.""" def test_acks_within_zero_fires_immediately(self) -> None: meta = _conv_meta(state=ChannelState.PENDING, pending_acks=["bob"]) evaluator = AcksWithinEvaluator() expectation = Expectation(name="acks_within", on_violation="audit", params={"seconds": 0}) # Same instant as creation — `elapsed=0`, `0 < 0` is False → fires. violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:00:00+00:00")) assert violation is not None def test_max_silence_zero_fires_immediately(self) -> None: meta = _conv_meta(state=ChannelState.ACTIVE, created_at="2026-01-01T00:00:00+00:00") evaluator = MaxSilenceEvaluator() expectation = Expectation(name="max_silence", on_violation="audit", params={"seconds": 0}) violation = evaluator.evaluate(expectation, _ctx(meta, now="2026-01-01T00:00:00+00:00")) assert violation is not None @pytest.mark.asyncio async def test_handler_exception_does_not_stop_sweeper() -> None: """A custom handler that raises must not crash the sweeper or block other handlers from firing on the same tick. The hub wraps each handler call in ``contextlib.suppress(Exception)`` and still marks the violation as fired (so we don't infinite-loop on a bad handler). """ clock = _MockClock(start="2026-01-01T00:00:00+00:00") hub = await Hub.open( MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0, # we drive ticks manually clock=clock, ) handler_calls: list[tuple[str, str]] = [] class CrashHandler: name = "crash" async def handle(self, _hub: Hub, channel_id: str, _violation: object) -> None: handler_calls.append(("crash", channel_id)) raise RuntimeError("boom") hub.register_violation_handler(CrashHandler()) hub.register_adapter( _NoOpAdapter( ChannelManifest( type="crash_test", version=1, participants=ParticipantSchema(min=2), expectations=[ Expectation(name="acks_within", on_violation="crash", params={"seconds": 0}), ], ) ) ) _inject_pending_channel(hub, channel_id="s-crash", adapter_key=("crash_test", 1), clock=clock) # First tick: handler raises but sweeper survives. clock.advance(1) # 1s elapsed > 0s threshold await hub._expectation_tick() assert len(handler_calls) == 1 # called once, raised, but tracked # Second tick: violation deduped, handler NOT called again. clock.advance(1) await hub._expectation_tick() assert len(handler_calls) == 1 # no re-fire await hub.close() @pytest.mark.asyncio async def test_two_same_name_expectations_with_different_handlers_both_fire() -> None: """A manifest can list the same evaluator name twice with different ``on_violation`` handlers (e.g. ``warn`` at 120s + ``auto_close`` at 600s). The position-based key ``(idx, name, violator)`` must disambiguate so both fire when the larger threshold is also crossed. """ clock = _MockClock(start="2026-01-01T00:00:00+00:00") hub = await Hub.open( MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0, clock=clock, ) fired: list[tuple[int, str]] = [] class WarnHandler: name = "warn" async def handle(self, _hub: Hub, _channel_id: str, _violation: object) -> None: fired.append((0, "warn")) class AuditHandler2: name = "audit2" async def handle(self, _hub: Hub, _channel_id: str, _violation: object) -> None: fired.append((1, "audit2")) hub.register_violation_handler(WarnHandler()) hub.register_violation_handler(AuditHandler2()) hub.register_adapter( _NoOpAdapter( ChannelManifest( type="dual_test", version=1, participants=ParticipantSchema(min=2), expectations=[ Expectation(name="acks_within", on_violation="warn", params={"seconds": 30}), Expectation(name="acks_within", on_violation="audit2", params={"seconds": 60}), ], ) ) ) _inject_pending_channel(hub, channel_id="s-dual", adapter_key=("dual_test", 1), clock=clock) # 35s in: only the 30s expectation fires. clock.advance(35) await hub._expectation_tick() assert (0, "warn") in fired assert (1, "audit2") not in fired # 65s in: BOTH expectations should now have fired. clock.advance(30) # total 65s await hub._expectation_tick() assert (0, "warn") in fired # still there assert (1, "audit2") in fired # NEW # Each handler fired exactly once (dedup intact). assert sum(1 for x in fired if x == (0, "warn")) == 1 assert sum(1 for x in fired if x == (1, "audit2")) == 1 await hub.close() @pytest.mark.asyncio async def test_unknown_evaluator_name_silently_ignored() -> None: """An expectation referencing an unregistered evaluator should not crash the sweeper — it's silently skipped.""" clock = _MockClock() hub = await Hub.open( MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0, clock=clock, ) hub.register_adapter( _NoOpAdapter( ChannelManifest( type="bogus", version=1, participants=ParticipantSchema(min=2), expectations=[ Expectation(name="nonexistent_evaluator", on_violation="audit", params={}), ], ) ) ) _inject_pending_channel(hub, channel_id="s-bogus", adapter_key=("bogus", 1), clock=clock) clock.advance(60) # Must not raise. await hub._expectation_tick() await hub.close() @pytest.mark.asyncio async def test_unknown_handler_name_silently_ignored() -> None: """If the evaluator fires but the named handler isn't registered, no record is written but the sweeper continues.""" clock = _MockClock() hub = await Hub.open( MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0, clock=clock, ) hub.register_adapter( _NoOpAdapter( ChannelManifest( type="ghost", version=1, participants=ParticipantSchema(min=2), expectations=[ Expectation(name="acks_within", on_violation="ghost_handler", params={"seconds": 0}), ], ) ) ) _inject_pending_channel(hub, channel_id="s-ghost", adapter_key=("ghost", 1), clock=clock) clock.advance(1) # Must not raise. Audit log should also stay empty since no # handler was found to record anything. await hub._expectation_tick() audit = AuditLog(hub._store) records = await audit.read_all() violation_records = [r for r in records if r["kind"] == AUDIT_KIND_EXPECTATION_VIOLATED] assert violation_records == [] await hub.close()