test/network/test_conversation.py
Rama main · réplica de lectura del repositorio original.
# 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",
)