Ctrl K
ComunidadGuías…

test/network/test_discussion.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

"""Discussion adapter (round_robin) + multi-party handshake tests.

Covers:

* 5-way handshake: hub posts ``EV_CHANNEL_INVITE`` to every invitee,
  collects all-or-nothing acks, transitions to ``ACTIVE``.
* Round-robin speaker rotation: ``state.expected_next_speaker`` cycles
  through ``metadata.participants`` ``order`` after each accepted
  ``EV_TEXT``.
* ``validate_send`` rejects out-of-turn sends.
* Partial reject fails the channel (the handshake is all-or-nothing).
* ``Hub.hydrate()`` re-folds the WAL through ``DiscussionAdapter.fold``
  and recovers ``expected_next_speaker``.
* Hub auto-registers the adapter on ``Hub.open``.
* ``validate_create`` rejects unsupported ordering modes.

This suite uses ``ScriptedConfig`` so it runs offline and fast.
"""

import contextlib
from collections.abc import Awaitable, Callable

import pytest

from ag2 import Agent
from ag2.knowledge import DiskKnowledgeStore, MemoryKnowledgeStore
from ag2.network import (
    EV_CHANNEL_INVITE,
    EV_CHANNEL_INVITE_ACK,
    EV_CHANNEL_INVITE_REJECT,
    EV_CHANNEL_OPENED,
    EV_TEXT,
    Envelope,
    Hub,
    HubClient,
    LocalLink,
    Passport,
    Resume,
)
from ag2.network.adapters.discussion import (
    DISCUSSION_TYPE,
    ORDERING_ROUND_ROBIN,
    DiscussionAdapter,
    DiscussionState,
)
from ag2.network.channel import ChannelState
from ag2.network.client.agent_client import AgentClient
from ag2.network.errors import ProtocolError
from ag2.testing import TestConfig

from ._helpers import ScriptedConfig, wait_for_text_count


def _agent(name: str, *events: object) -> Agent:
    return Agent(name=name, config=TestConfig(*events))


def _scripted_agent(name: str, *replies: str) -> Agent:
    return Agent(name=name, config=ScriptedConfig(*replies))


def _make_rejecter(client: AgentClient) -> Callable[[Envelope], Awaitable[None]]:
    """Build a notify handler that explicitly rejects every invite.

    Used to drive the partial-reject path. Defined at module level
    (not inside a test) so the closure is created once at registration.
    """

    async def _reject(envelope: Envelope) -> None:
        if envelope.event_type != EV_CHANNEL_INVITE:
            return
        rejection = Envelope(
            channel_id=envelope.channel_id,
            sender_id=client.agent_id,
            audience=None,
            event_type=EV_CHANNEL_INVITE_REJECT,
            event_data={"reason": "not interested"},
            causation_id=envelope.envelope_id,
        )
        with contextlib.suppress(Exception):
            await client.send_envelope(rejection)

    return _reject


@pytest.mark.asyncio
async def test_default_discussion_adapter_registered_on_open() -> None:
    """``Hub.open`` auto-registers DiscussionAdapter for ``discussion@v1``."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)
    adapter = hub._adapters.get((DISCUSSION_TYPE, 1))
    assert isinstance(adapter, DiscussionAdapter)
    await hub.close()


@pytest.mark.asyncio
async def test_discussion_validate_create_rejects_unsupported_ordering() -> None:
    """V1 only ships round_robin; dynamic / static raise at create time."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))

    with pytest.raises(ProtocolError, match="ordering"):
        await hub.create_channel(
            creator_id=alice.agent_id,
            manifest_type=DISCUSSION_TYPE,
            participants=[bob.agent_id],
            knobs={"ordering": "dynamic"},
        )

    await hub.close()


@pytest.mark.asyncio
async def test_discussion_5_way_handshake_transitions_to_active() -> None:
    """5 invitees all auto-ack; channel activates with full participant list."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)
    link = LocalLink(hub)

    names = ["alice", "bob", "carol", "dave", "erin"]
    clients: list[AgentClient] = []
    for name in names:
        hc = HubClient(link, hub=hub)
        client = await hc.register(_agent(name), Passport(name=name), Resume())
        clients.append(client)

    alice = clients[0]
    targets = [c.agent_id for c in clients[1:]]
    channel = await alice.open(
        type=DISCUSSION_TYPE,
        target=targets,
        knobs={"ordering": ORDERING_ROUND_ROBIN},
    )

    assert channel.state == ChannelState.ACTIVE
    assert channel.metadata.pending_acks == []
    assert channel.metadata.rejected_by == []
    assert {p.agent_id for p in channel.metadata.participants} == {c.agent_id for c in clients}

    wal = await hub.read_wal(channel.channel_id)
    invite_count = sum(1 for e in wal if e.event_type == EV_CHANNEL_INVITE)
    ack_count = sum(1 for e in wal if e.event_type == EV_CHANNEL_INVITE_ACK)
    assert invite_count == 4  # one invite per non-creator participant
    assert ack_count == 4
    assert any(e.event_type == EV_CHANNEL_OPENED for e in wal)

    state = hub._adapter_states[channel.channel_id]
    assert isinstance(state, DiscussionState)
    assert state.expected_next_speaker == alice.agent_id
    assert state.participant_order[0] == alice.agent_id

    for hc in [c._hub_client for c in clients]:
        await hc.close()
    await hub.close()


@pytest.mark.asyncio
async def test_discussion_round_robin_advances_through_participants() -> None:
    """Manual sends in turn order succeed; out-of-turn raises ProtocolError."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

    # Use plain TestConfig agents and skip the LLM auto-handler — we
    # only exercise the adapter by posting envelopes manually.
    alice = await hub.register(_agent("alice"), attach_plugin=False)
    bob = await hub.register(_agent("bob"), attach_plugin=False)
    carol = await hub.register(_agent("carol"), attach_plugin=False)

    # Auto-ack invites (handlers are off — install minimal ackers).
    for client in (bob, carol):
        client.on_envelope(_make_auto_acker(client))

    channel = await alice.open(
        type=DISCUSSION_TYPE,
        target=[bob.agent_id, carol.agent_id],
        knobs={"ordering": ORDERING_ROUND_ROBIN},
    )
    assert channel.state == ChannelState.ACTIVE

    # Round 1: alice → bob → carol.
    await channel.send("alice 1")
    state = hub._adapter_states[channel.channel_id]
    assert state.expected_next_speaker == bob.agent_id

    bob_envelope = Envelope(
        channel_id=channel.channel_id,
        sender_id=bob.agent_id,
        audience=None,
        event_type=EV_TEXT,
        event_data={"text": "bob 1"},
    )
    await hub.post_envelope(bob_envelope)
    state = hub._adapter_states[channel.channel_id]
    assert state.expected_next_speaker == carol.agent_id

    carol_envelope = Envelope(
        channel_id=channel.channel_id,
        sender_id=carol.agent_id,
        audience=None,
        event_type=EV_TEXT,
        event_data={"text": "carol 1"},
    )
    await hub.post_envelope(carol_envelope)
    state = hub._adapter_states[channel.channel_id]
    assert state.expected_next_speaker == alice.agent_id  # cycle back
    assert state.turn_count == 3
    assert state.last_speaker_id == carol.agent_id

    await hub.close()


@pytest.mark.asyncio
async def test_discussion_rejects_out_of_turn_send() -> None:
    """Sending out of round-robin order raises ProtocolError before WAL write."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))
    carol = await hub.register(_agent("carol"))

    channel = await alice.open(
        type=DISCUSSION_TYPE,
        target=[bob.agent_id, carol.agent_id],
        knobs={"ordering": ORDERING_ROUND_ROBIN},
    )

    # Carol jumps the queue before alice has spoken.
    bad = Envelope(
        channel_id=channel.channel_id,
        sender_id=carol.agent_id,
        audience=None,
        event_type=EV_TEXT,
        event_data={"text": "I'm jumping the queue"},
    )
    with pytest.raises(ProtocolError, match="expects"):
        await hub.post_envelope(bad)

    await hub.close()


@pytest.mark.asyncio
async def test_discussion_partial_reject_fails_channel() -> None:
    """V1 all-or-nothing: any reject during handshake closes the channel."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0, invite_ack_timeout=2.0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))
    # Carol rejects every invite.
    carol = await hub.register(_agent("carol"), attach_plugin=False)
    carol.on_envelope(_make_rejecter(carol))

    with pytest.raises(ProtocolError, match="rejected"):
        await alice.open(
            type=DISCUSSION_TYPE,
            target=[bob.agent_id, carol.agent_id],
            knobs={"ordering": ORDERING_ROUND_ROBIN},
        )

    await hub.close()


@pytest.mark.asyncio
async def test_discussion_hydrate_refolds_round_robin_state(tmp_path) -> None:
    """Re-opening hub mid-discussion recovers expected_next_speaker."""
    store = DiskKnowledgeStore(str(tmp_path))
    hub1 = await Hub.open(store, ttl_sweep_interval=0)

    alice = await hub1.register(_agent("alice"), attach_plugin=False)
    bob = await hub1.register(_agent("bob"), attach_plugin=False)
    carol = await hub1.register(_agent("carol"), attach_plugin=False)
    bob.on_envelope(_make_auto_acker(bob))
    carol.on_envelope(_make_auto_acker(carol))

    channel = await alice.open(
        type=DISCUSSION_TYPE,
        target=[bob.agent_id, carol.agent_id],
        knobs={"ordering": ORDERING_ROUND_ROBIN},
    )
    await channel.send("alice opens the floor")

    # Mid-discussion (bob is up next) → tear down the hub.
    await hub1.close()

    # Reopen against the same store.
    store2 = DiskKnowledgeStore(str(tmp_path))
    hub2 = await Hub.open(store2, ttl_sweep_interval=0)

    state = hub2._adapter_states[channel.channel_id]
    assert isinstance(state, DiscussionState)
    assert state.expected_next_speaker == bob.agent_id
    assert state.last_speaker_id == alice.agent_id
    assert state.turn_count == 1
    assert state.participant_order == [alice.agent_id, bob.agent_id, carol.agent_id]

    await hub2.close()


@pytest.mark.asyncio
async def test_discussion_llm_driven_round_robin_3_way() -> None:
    """End-to-end LLM-driven 3-way round-robin: each agent's handler fires
    only when the adapter's validate_send would accept their reply."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

    # alice's manual send opens turn 1; her LLM speaks again on her turn 2.
    alice = await hub.register(_scripted_agent("alice", "alice 2"))
    bob = await hub.register(_scripted_agent("bob", "bob 1"))
    carol = await hub.register(_scripted_agent("carol", "carol 1"))

    channel = await alice.open(
        type=DISCUSSION_TYPE,
        target=[bob.agent_id, carol.agent_id],
        knobs={"ordering": ORDERING_ROUND_ROBIN},
    )
    await channel.send("alice 1")

    # 4 EV_TEXT envelopes total: alice 1 / bob 1 / carol 1 / alice 2.
    # On alice's turn 3, bob has no script left → empty body → halt.
    wal = await wait_for_text_count(hub, channel.channel_id, expected=4)
    text_envelopes = [e for e in wal if e.event_type == EV_TEXT]
    assert [e.event_data["text"] for e in text_envelopes] == [
        "alice 1",
        "bob 1",
        "carol 1",
        "alice 2",
    ]
    assert [e.sender_id for e in text_envelopes] == [
        alice.agent_id,
        bob.agent_id,
        carol.agent_id,
        alice.agent_id,
    ]

    state = hub._adapter_states[channel.channel_id]
    assert state.expected_next_speaker == bob.agent_id
    assert state.turn_count == 4

    await hub.close()


def _make_auto_acker(client: AgentClient) -> Callable[[Envelope], Awaitable[None]]:
    """Minimal handler that auto-acks invites and ignores everything else.

    Used by tests that disable the default handler (which also runs
    LLM logic) but still need the multi-party handshake to complete.
    """

    async def _ack(envelope: Envelope) -> None:
        if envelope.event_type != EV_CHANNEL_INVITE:
            return
        ack = Envelope(
            channel_id=envelope.channel_id,
            sender_id=client.agent_id,
            audience=None,
            event_type=EV_CHANNEL_INVITE_ACK,
            event_data={"channel_id": envelope.channel_id},
            causation_id=envelope.envelope_id,
        )
        with contextlib.suppress(Exception):
            await client.send_envelope(ack)

    return _ack