# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 """Shared scaffolding for network integration tests. * :class:`ScriptedConfig` — ``ModelConfig`` whose script persists across multiple ``create()`` calls, unlike ``ag2.testing.TestConfig`` which resets its iterator each turn (breaks multi-turn LLM tests). * :func:`wait_for_text_count` — poll a channel's WAL until ``EV_TEXT`` count reaches a threshold; conversation/discussion adapters have no terminal event to await on. * :func:`wait_for_delivery` — poll one recipient's captured envelopes until a given text lands; for per-recipient delivery assertions. """ import asyncio from collections.abc import Sequence from datetime import UTC, datetime, timedelta from typing import Any from ag2 import Context from ag2.config import LLMClient, ModelConfig from ag2.events import BaseEvent, ModelMessage, ModelResponse from ag2.network import EV_TEXT, Envelope, Hub from ag2.network.adapters.conversation import ConversationAdapter from ag2.network.channel import ChannelMetadata, ChannelState, Participant, ParticipantRole __all__ = ( "ScriptedConfig", "_MockClock", "text_envelope", "two_party_metadata", "wait_for_delivery", "wait_for_text_count", ) class _MockClock: """Controllable clock — returns a stored ISO timestamp; ``advance`` pushes it forward by ``seconds``.""" def __init__(self, start: str = "2026-01-01T00:00:00+00:00") -> None: self._now = datetime.fromisoformat(start) if self._now.tzinfo is None: self._now = self._now.replace(tzinfo=UTC) def __call__(self) -> str: return self._now.isoformat() def advance(self, seconds: float) -> None: self._now = self._now + timedelta(seconds=seconds) def two_party_metadata(a_id: str, b_id: str) -> ChannelMetadata: """Synthesize a minimal conversation-style channel for projection tests.""" return ChannelMetadata( channel_id="ch-test", manifest=ConversationAdapter().manifest, creator_id=a_id, participants=[ Participant(agent_id=a_id, role=ParticipantRole.INITIATOR, order=0, joined_at="now"), Participant(agent_id=b_id, role=ParticipantRole.RESPONDENT, order=1, joined_at="now"), ], state=ChannelState.ACTIVE, created_at="now", expires_at=None, knobs={}, labels={}, required_acks=None, pending_acks=[], ) def text_envelope(sender: str, audience: str, text: str) -> Envelope: return Envelope( channel_id="ch-test", sender_id=sender, audience=[audience], event_type=EV_TEXT, event_data={"text": text}, ) class ScriptedConfig(ModelConfig): """Test config whose reply script persists across ``create()`` calls. ``Agent.ask`` calls ``config.create()`` once per turn; the standard ``TestConfig`` builds a fresh iterator each time, so a single agent keeps replaying its first scripted reply on every turn. This wrapper feeds one shared cursor into every client it produces, so a single agent can answer N successive turns deterministically. Exhausted scripts return ``""`` — the network's default handler treats an empty reply body as "don't send," which halts auto-driven LLM exchanges cleanly. """ def __init__(self, *replies: str) -> None: self._replies: list[str] = list(replies) self._cursor = 0 def copy(self) -> "ScriptedConfig": return self def create(self) -> "_ScriptedClient": return _ScriptedClient(self) def create_files_client(self) -> None: raise NotImplementedError("ScriptedConfig has no Files API") def _next_reply(self) -> str: if self._cursor >= len(self._replies): return "" reply = self._replies[self._cursor] self._cursor += 1 return reply class _ScriptedClient(LLMClient): def __init__(self, config: ScriptedConfig) -> None: self._config = config async def __call__( self, messages: Sequence[BaseEvent], context: Context, **kwargs: Any, ) -> ModelResponse: text = self._config._next_reply() message = ModelMessage(text) await context.send(message) return ModelResponse(message) async def wait_for_text_count( hub: Hub, channel_id: str, expected: int, *, timeout: float = 5.0, ) -> list[Envelope]: """Poll WAL until at least ``expected`` ``EV_TEXT`` envelopes appear. Used by adapters that have no terminal event to await on (``conversation``, ``discussion``). Auto-driven LLM exchanges settle when one side returns an empty body; tests poll rather than assume timing. """ deadline = asyncio.get_event_loop().time() + timeout while asyncio.get_event_loop().time() < deadline: wal = await hub.read_wal(channel_id) if sum(1 for e in wal if e.event_type == EV_TEXT) >= expected: return wal await asyncio.sleep(0.02) raise TimeoutError(f"channel {channel_id!r} never reached {expected} EV_TEXT envelopes") async def wait_for_delivery( received: "Sequence[Envelope]", text: str, *, timeout: float = 5.0, ) -> None: """Poll a capture list until an ``EV_TEXT`` envelope carrying ``text`` lands. The hub fans out to each recipient in turn, so one participant's copy can land several event-loop turns after another's; polling avoids encoding a guess about the slowest recipient. """ deadline = asyncio.get_event_loop().time() + timeout while asyncio.get_event_loop().time() < deadline: if any(e.event_type == EV_TEXT and e.event_data.get("text") == text for e in received): return await asyncio.sleep(0.02) raise TimeoutError(f"no EV_TEXT envelope carrying {text!r} was delivered within {timeout}s")