test/network/test_transport_demux.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
"""Transport / NotifyFrame demux invariants — multiple agents per
HubClient, broadcast vs targeted, multi-tenant isolation, unregister
cleanup.
The hub stamps ``recipient_id`` on every dispatched ``NotifyFrame``
so the receiving HubClient can demux directly to the right
AgentClient without re-walking channel participants. Tests here
validate that demux against the real frame path (LocalLink) for the
scenarios that are otherwise easy to get subtly wrong.
"""
import asyncio
import pytest
from ag2 import Agent
from ag2.knowledge import MemoryKnowledgeStore
from ag2.network import (
EV_TEXT,
Envelope,
Hub,
HubClient,
LocalLink,
Passport,
Resume,
)
from ._helpers import ScriptedConfig, wait_for_delivery
# Window for a forbidden delivery to show up before a negative assertion runs.
SETTLE = 0.05
def _agent(name: str) -> Agent:
return Agent(name=name, config=ScriptedConfig())
@pytest.mark.asyncio
async def test_multiple_agents_share_single_hub_client_endpoint() -> None:
"""A HubClient hosts N AgentClients on a single LocalLink connection.
The hub binds every agent_id registered through the same HubClient
to that HubClient's one endpoint. Demux happens at the AgentClient
level via the per-frame ``recipient_id`` stamp.
"""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
alice = await hc.register(_agent("alice"), Passport(name="alice"), Resume())
bob = await hc.register(_agent("bob"), Passport(name="bob"), Resume())
carol = await hc.register(_agent("carol"), Passport(name="carol"), Resume())
shared = hub._agent_to_endpoint[alice.agent_id]
assert hub._agent_to_endpoint[bob.agent_id] == shared
assert hub._agent_to_endpoint[carol.agent_id] == shared
assert hub._endpoint_to_agents[shared] == {alice.agent_id, bob.agent_id, carol.agent_id}
await hc.close()
await hub.close()
@pytest.mark.asyncio
async def test_targeted_envelope_delivered_to_only_named_recipient() -> None:
"""Per-frame ``recipient_id`` stamping: an envelope addressed to
bob is delivered ONLY to bob, even when the same HubClient hosts
multiple identities."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
alice = await hc.register(_agent("alice"), Passport(name="alice"), Resume())
bob = await hc.register(_agent("bob"), Passport(name="bob"), Resume())
carol = await hc.register(_agent("carol"), Passport(name="carol"), Resume())
# Wrap each agent's notify handler with a capture that preserves the original.
received_alice: list[Envelope] = []
received_bob: list[Envelope] = []
received_carol: list[Envelope] = []
orig_alice, orig_bob, orig_carol = alice._on_envelope, bob._on_envelope, carol._on_envelope
async def cap_alice(env):
received_alice.append(env)
if orig_alice is not None:
await orig_alice(env)
async def cap_bob(env):
received_bob.append(env)
if orig_bob is not None:
await orig_bob(env)
async def cap_carol(env):
received_carol.append(env)
if orig_carol is not None:
await orig_carol(env)
alice.on_envelope(cap_alice)
bob.on_envelope(cap_bob)
carol.on_envelope(cap_carol)
# Alice opens a 3-way discussion with bob+carol.
channel = await alice.open(type="discussion", target=["bob", "carol"])
# Targeted to bob only.
await channel.send("private to bob", audience=[bob.agent_id])
await wait_for_delivery(received_bob, "private to bob")
await asyncio.sleep(SETTLE) # let carol/alice deliveries land, if any
bob_text = [e for e in received_bob if e.event_type == EV_TEXT and e.event_data.get("text") == "private to bob"]
carol_text = [e for e in received_carol if e.event_type == EV_TEXT and e.event_data.get("text") == "private to bob"]
alice_text = [e for e in received_alice if e.event_type == EV_TEXT and e.event_data.get("text") == "private to bob"]
assert len(bob_text) == 1, "bob must receive the targeted envelope"
assert carol_text == [], "carol must NOT receive a targeted-to-bob envelope"
assert alice_text == [], "sender must NOT echo its own send back to itself"
await hc.close()
await hub.close()
@pytest.mark.asyncio
async def test_broadcast_envelope_delivered_to_all_non_sender() -> None:
"""An envelope with ``audience=None`` reaches every participant
except the sender."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
alice = await hc.register(_agent("alice"), Passport(name="alice"), Resume())
bob = await hc.register(_agent("bob"), Passport(name="bob"), Resume())
carol = await hc.register(_agent("carol"), Passport(name="carol"), Resume())
received_bob: list[Envelope] = []
received_carol: list[Envelope] = []
received_alice: list[Envelope] = []
orig_b, orig_c, orig_a = bob._on_envelope, carol._on_envelope, alice._on_envelope
async def cap_b(env):
received_bob.append(env)
if orig_b:
await orig_b(env)
async def cap_c(env):
received_carol.append(env)
if orig_c:
await orig_c(env)
async def cap_a(env):
received_alice.append(env)
if orig_a:
await orig_a(env)
bob.on_envelope(cap_b)
carol.on_envelope(cap_c)
alice.on_envelope(cap_a)
channel = await alice.open(type="discussion", target=["bob", "carol"])
await channel.send("hello everyone", audience=None)
# Each recipient awaited separately: carol's copy lands after bob's.
await wait_for_delivery(received_bob, "hello everyone")
await wait_for_delivery(received_carol, "hello everyone")
await asyncio.sleep(SETTLE) # let an alice delivery land, if any
# Both non-sender participants see it; alice (sender) does not.
assert any(e.event_data.get("text") == "hello everyone" for e in received_bob)
assert any(e.event_data.get("text") == "hello everyone" for e in received_carol)
assert all(e.event_data.get("text") != "hello everyone" for e in received_alice)
await hc.close()
await hub.close()
@pytest.mark.asyncio
async def test_unregister_cleans_endpoint_binding() -> None:
"""After unregister, the agent_id no longer maps to any endpoint."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
alice = await hc.register(_agent("alice"), Passport(name="alice"), Resume())
bob = await hc.register(_agent("bob"), Passport(name="bob"), Resume())
alice_id = alice.agent_id
shared_endpoint_id = hub._agent_to_endpoint[alice_id]
# Both agents on same endpoint initially.
assert alice_id in hub._endpoint_to_agents[shared_endpoint_id]
assert bob.agent_id in hub._endpoint_to_agents[shared_endpoint_id]
await alice.unregister()
# Alice's binding gone; bob still bound to the same endpoint.
assert alice_id not in hub._agent_to_endpoint
assert alice_id not in hub._endpoint_to_agents.get(shared_endpoint_id, set())
assert bob.agent_id in hub._endpoint_to_agents[shared_endpoint_id]
await hc.close()
await hub.close()
@pytest.mark.asyncio
async def test_two_hub_clients_share_one_hub_isolated_endpoints() -> None:
"""Two HubClients (e.g. simulating two tenant processes) on the
same in-process Hub get separate endpoints. Envelopes for tenant
A's agents do NOT reach tenant B."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc_a = HubClient(link, hub=hub)
hc_b = HubClient(link, hub=hub)
alice = await hc_a.register(_agent("alice"), Passport(name="alice"), Resume())
bob = await hc_b.register(_agent("bob"), Passport(name="bob"), Resume())
# Different endpoints.
assert hub._agent_to_endpoint[alice.agent_id] != hub._agent_to_endpoint[bob.agent_id]
received_bob: list[Envelope] = []
orig_b = bob._on_envelope
async def cap_b(env):
received_bob.append(env)
if orig_b:
await orig_b(env)
bob.on_envelope(cap_b)
channel = await alice.open(type="conversation", target="bob")
await channel.send("cross-tenant", audience=[bob.agent_id])
await wait_for_delivery(received_bob, "cross-tenant")
assert any(e.event_data.get("text") == "cross-tenant" for e in received_bob)
# And the cross-tenant envelope is registered in alice's HubClient
# too — but only routed to her own AgentClient (none here besides
# alice). Verify by checking that hc_a only has alice in _clients.
assert set(hc_a._clients.keys()) == {alice.agent_id}
assert set(hc_b._clients.keys()) == {bob.agent_id}
await hc_a.close()
await hc_b.close()
await hub.close()
@pytest.mark.asyncio
async def test_hub_client_close_shuts_down_link() -> None:
"""HubClient.close() should close its LocalLinkClient, terminating
the receive loop. Subsequent register attempts must raise."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
await hc.register(_agent("alice"), Passport(name="alice"), Resume())
await hc.close()
with pytest.raises(RuntimeError, match="closed"):
await hc.register(_agent("bob"), Passport(name="bob"), Resume())
await hub.close()
@pytest.mark.asyncio
async def test_hub_close_idempotent_after_endpoint_attached() -> None:
"""Hub.close() with attached endpoint tasks — calling twice is safe."""
hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0, expectation_sweep_interval=0)
link = LocalLink(hub)
hc = HubClient(link, hub=hub)
await hc.register(_agent("alice"), Passport(name="alice"), Resume())
await hc.close()
await hub.close()
await hub.close() # idempotent