Ctrl K
ComunidadGuías…

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

"""Conversation adapter + windowed summary view tests.

Covers:

* ``ConversationAdapter`` 1+1 bidirectional handshake.
* Multi-turn LLM-driven back-and-forth (no auto-close).
* Explicit ``channel.close()`` ends the conversation.
* ``validate_send`` rejects sends from non-participants.
* Adapter survives ``Hub.hydrate()`` mid-conversation.
* ``WindowedSummary`` view: short history passes through; long history
  prepends a ``CompactionSummary``; respects ``audience`` visibility.

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

import pytest

from ag2 import Agent
from ag2.compact import CompactionSummary
from ag2.events import ModelMessage, ModelRequest, TextInput
from ag2.knowledge import DiskKnowledgeStore, MemoryKnowledgeStore
from ag2.network import (
    EV_CHANNEL_INVITE,
    EV_CHANNEL_INVITE_ACK,
    EV_CHANNEL_OPENED,
    EV_TEXT,
    Envelope,
    Hub,
)
from ag2.network.adapters.base import default_render_envelope
from ag2.network.adapters.conversation import (
    CONVERSATION_TYPE,
    ConversationAdapter,
    ConversationState,
)
from ag2.network.channel import (
    ChannelMetadata,
    ChannelState,
    Participant,
    ParticipantRole,
)
from ag2.network.errors import ProtocolError
from ag2.network.views.builtin import WindowedSummary
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))


@pytest.mark.asyncio
async def test_conversation_handshake_transitions_to_active() -> None:
    """alice.open(type=conversation) → bob auto-acks → ACTIVE."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

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

    channel = await alice.open(type=CONVERSATION_TYPE, target="bob")
    assert channel.state == ChannelState.ACTIVE
    assert channel.metadata.creator_id == alice.agent_id
    assert {p.agent_id for p in channel.metadata.participants} == {
        alice.agent_id,
        bob.agent_id,
    }
    assert channel.metadata.pending_acks == []

    wal = await hub.read_wal(channel.channel_id)
    event_types = [e.event_type for e in wal]
    assert EV_CHANNEL_INVITE in event_types
    assert EV_CHANNEL_INVITE_ACK in event_types
    assert EV_CHANNEL_OPENED in event_types

    await hub.close()


@pytest.mark.asyncio
async def test_conversation_back_and_forth_multi_turn() -> None:
    """Bidirectional LLM-driven exchange runs until one side returns empty."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

    # alice replies to bob's first answer with a follow-up, then halts.
    alice = await hub.register(_scripted_agent("alice", "follow up question"))
    # bob answers alice twice; second reply is empty → halts the chain.
    bob = await hub.register(_scripted_agent("bob", "initial reply", "second answer"))

    channel = await alice.open(type=CONVERSATION_TYPE, target="bob")
    await channel.send("hello bob")

    # Expect 4 EV_TEXT envelopes total: alice → bob → alice → bob.
    wal = await wait_for_text_count(hub, channel.channel_id, expected=4)
    text_events = [e for e in wal if e.event_type == EV_TEXT]
    assert [e.event_data["text"] for e in text_events] == [
        "hello bob",
        "initial reply",
        "follow up question",
        "second answer",
    ]
    assert [e.sender_id for e in text_events] == [
        alice.agent_id,
        bob.agent_id,
        alice.agent_id,
        bob.agent_id,
    ]

    # Adapter state reflects the final speaker + count.
    state = hub._adapter_states[channel.channel_id]
    assert isinstance(state, ConversationState)
    assert state.turn_count == 4
    assert state.last_speaker_id == bob.agent_id

    await hub.close()


@pytest.mark.asyncio
async def test_conversation_explicit_close_terminates() -> None:
    """``channel.close()`` transitions to ``CLOSED`` regardless of activity."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

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

    channel = await alice.open(type=CONVERSATION_TYPE, target="bob")
    closed = await channel.close(reason="done")

    assert closed.state == ChannelState.CLOSED
    assert closed.close_reason == "done"

    # Subsequent sends rejected — channel is terminal.
    extra = Envelope(
        channel_id=channel.channel_id,
        sender_id=alice.agent_id,
        audience=[bob.agent_id],
        event_type=EV_TEXT,
        event_data={"text": "after close"},
    )
    with pytest.raises(ProtocolError):
        await hub.post_envelope(extra)

    await hub.close()


@pytest.mark.asyncio
async def test_conversation_rejects_send_from_non_participant() -> None:
    """``validate_send`` blocks sends from agents not in ``participants``."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0)

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

    channel = await alice.open(type=CONVERSATION_TYPE, target="bob")

    intruder = Envelope(
        channel_id=channel.channel_id,
        sender_id=eve.agent_id,
        audience=[alice.agent_id],
        event_type=EV_TEXT,
        event_data={"text": "I'm gatecrashing"},
    )
    with pytest.raises(ProtocolError, match="participants"):
        await hub.post_envelope(intruder)

    await hub.close()


@pytest.mark.asyncio
async def test_conversation_hydrate_refolds_active_channel(tmp_path) -> None:
    """Re-opening the hub mid-conversation rebuilds adapter state from WAL."""
    store = DiskKnowledgeStore(str(tmp_path))
    hub1 = await Hub.open(store, ttl_sweep_interval=0)

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

    channel = await alice.open(type=CONVERSATION_TYPE, target="bob")
    await channel.send("first turn")
    # No LLM responses configured — bob's handler exhausts; conversation
    # quiesces with one EV_TEXT in WAL.
    await wait_for_text_count(hub1, channel.channel_id, expected=1)

    await hub1.close()

    store2 = DiskKnowledgeStore(str(tmp_path))
    hub2 = await Hub.open(store2, ttl_sweep_interval=0)

    refreshed = await hub2.get_channel(channel.channel_id)
    assert refreshed.manifest.type == CONVERSATION_TYPE
    assert refreshed.state == ChannelState.ACTIVE

    state = hub2._adapter_states[channel.channel_id]
    assert isinstance(state, ConversationState)
    assert state.turn_count == 1
    assert state.last_speaker_id == alice.agent_id

    await hub2.close()


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


@pytest.mark.asyncio
async def test_windowed_summary_short_history_passes_through() -> None:
    """When WAL ≤ recent_n, projection is full transcript without summary."""
    metadata = _two_party_metadata("alice", "bob")
    wal = [
        _text_envelope("alice", "bob", "hi"),
        _text_envelope("bob", "alice", "hello"),
        _text_envelope("alice", "bob", "how's it going"),
    ]

    view = WindowedSummary(recent_n=10)
    projection = await view.project(
        wal,
        participant_id="bob",
        channel=metadata,
        render_envelope=default_render_envelope,
    )

    assert projection == [
        ModelRequest([TextInput("hi")]),
        ModelMessage("hello"),
        ModelRequest([TextInput("how's it going")]),
    ]


@pytest.mark.asyncio
async def test_windowed_summary_long_history_prepends_compaction_summary() -> None:
    """When WAL > recent_n, older slice collapses to a CompactionSummary."""
    metadata = _two_party_metadata("alice", "bob")
    wal = [_text_envelope("alice", "bob", f"msg {i}") for i in range(8)]

    view = WindowedSummary(recent_n=3)
    projection = await view.project(
        wal,
        participant_id="bob",
        channel=metadata,
        render_envelope=default_render_envelope,
    )

    assert len(projection) == 4  # 1 summary + 3 recent
    head = projection[0]
    assert isinstance(head, CompactionSummary)
    assert head.event_count == 5
    assert "alice" in head.summary
    # Tail preserves recent envelopes verbatim.
    assert projection[1:] == [
        ModelRequest([TextInput("msg 5")]),
        ModelRequest([TextInput("msg 6")]),
        ModelRequest([TextInput("msg 7")]),
    ]


@pytest.mark.asyncio
async def test_windowed_summary_respects_audience_visibility() -> None:
    """Envelopes targeted at others (audience) are filtered out per participant."""
    metadata = _three_party_metadata("alice", "bob", "carol")
    wal = [
        _text_envelope("alice", None, "broadcast"),
        _text_envelope("alice", "bob", "private to bob", audience=["bob"]),
        _text_envelope("bob", None, "bob's reply"),
    ]

    view = WindowedSummary(recent_n=10)
    carol_projection = await view.project(
        wal,
        participant_id="carol",
        channel=metadata,
        render_envelope=default_render_envelope,
    )

    # Carol cannot see alice's private message to bob.
    assert carol_projection == [
        ModelRequest([TextInput("broadcast")]),
        ModelRequest([TextInput("bob's reply")]),
    ]


def _text_envelope(
    sender: str,
    recipient: str | None,
    text: str,
    *,
    audience: list[str] | None = None,
) -> Envelope:
    if audience is None and recipient is not None:
        audience = [recipient]
    return Envelope(
        envelope_id=f"env-{sender}-{text}",
        channel_id="channel-1",
        sender_id=sender,
        audience=audience,
        event_type=EV_TEXT,
        event_data={"text": text},
    )


def _two_party_metadata(initiator: str, respondent: str) -> ChannelMetadata:
    return ChannelMetadata(
        channel_id="channel-1",
        manifest=ConversationAdapter().manifest,
        creator_id=initiator,
        participants=[
            Participant(agent_id=initiator, role=ParticipantRole.INITIATOR, order=0),
            Participant(agent_id=respondent, role=ParticipantRole.RESPONDENT, order=1),
        ],
        state=ChannelState.ACTIVE,
        created_at="2026-05-03T00:00:00Z",
    )


def _three_party_metadata(initiator: str, *others: str) -> ChannelMetadata:
    participants = [
        Participant(agent_id=initiator, role=ParticipantRole.INITIATOR, order=0),
    ]
    for i, name in enumerate(others, start=1):
        participants.append(Participant(agent_id=name, role=ParticipantRole.PARTICIPANT, order=i))
    return ChannelMetadata(
        channel_id="channel-1",
        manifest=ConversationAdapter().manifest,
        creator_id=initiator,
        participants=participants,
        state=ChannelState.ACTIVE,
        created_at="2026-05-03T00:00:00Z",
    )