Ctrl K
ComunidadGuías…

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

"""End-to-end exercises for the WebSocket transport.

Each test starts a real ``serve_ws`` server on a loopback port and
connects a :class:`WsLinkClient` to it. The wire round-trip uses the
same Frame vocabulary as ``LocalLink`` plus real JSON encoding so any
serialisation issue surfaces here rather than in production.
"""

import asyncio

import pytest
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult, SpanExporter

from ag2 import Agent
from ag2.knowledge import MemoryKnowledgeStore
from ag2.network import (
    EV_TEXT,
    ApiKeyAuth,
    AuthBlock,
    AuthRegistry,
    Envelope,
    ErrorFrame,
    HelloFrame,
    Hub,
    HubClient,
    LocalLink,
    NoAuth,
    NotifyFrame,
    Passport,
    ReceiptFrame,
    Resume,
    WelcomeFrame,
    WsLinkClient,
    serve_ws,
)

from ._helpers import ScriptedConfig, wait_for_text_count


class _CapturingExporter(SpanExporter):
    """Minimal in-memory span sink for asserting on emitted spans."""

    def __init__(self) -> None:
        self.spans: list[ReadableSpan] = []

    def export(self, spans) -> SpanExportResult:
        self.spans.extend(spans)
        return SpanExportResult.SUCCESS

    def shutdown(self) -> None:
        self.spans.clear()


async def _wait_for_notify(client, text: str, timeout: float = 2.0) -> NotifyFrame:
    """Read frames until a ``NotifyFrame`` carrying ``text`` arrives."""
    deadline = asyncio.get_event_loop().time() + timeout
    while asyncio.get_event_loop().time() < deadline:
        try:
            frame = await asyncio.wait_for(_next_frame(client), 0.5)
        except TimeoutError:
            break
        if isinstance(frame, NotifyFrame) and frame.envelope.event_data.get("text") == text:
            return frame
    raise AssertionError(f"no NotifyFrame with text {text!r} arrived")


def _agent(name: str) -> Agent:
    return Agent(name=name, config=ScriptedConfig())


async def _next_frame(client, timeout: float = 2.0):
    async def first():
        async for frame in client.frames():
            return frame
        raise AssertionError("ws closed before a frame arrived")

    return await asyncio.wait_for(first(), timeout)


async def _new_hub() -> Hub:
    return await Hub.open(
        MemoryKnowledgeStore(),
        ttl_sweep_interval=0,
        expectation_sweep_interval=0,
    )


def _bound_port(server) -> int:
    return server.sockets[0].getsockname()[1]


class TestHandshake:
    @pytest.mark.asyncio
    async def test_hello_welcome_round_trip(self) -> None:
        hub = await _new_hub()
        await hub.register_identity(Passport(name="alice"), Resume())

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await client.open()
                await client.send_frame(HelloFrame(name="alice"))
                welcome = await _next_frame(client)
                assert isinstance(welcome, WelcomeFrame)
                assert welcome.endpoint_id != ""
                # The client cached the server-assigned endpoint id.
                assert client.endpoint_id == welcome.endpoint_id
            finally:
                await client.close()
        await hub.close()

    @pytest.mark.asyncio
    async def test_hello_rejects_unknown_name(self) -> None:
        hub = await _new_hub()
        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await client.open()
                await client.send_frame(HelloFrame(name="ghost"))
                frame = await _next_frame(client)
                assert isinstance(frame, ErrorFrame)
                assert frame.code == "not_found"
            finally:
                await client.close()
        await hub.close()


class TestAuthOverWire:
    @pytest.mark.asyncio
    async def test_api_key_happy_path(self) -> None:
        hub = await Hub.open(
            MemoryKnowledgeStore(),
            auth=AuthRegistry([NoAuth(), ApiKeyAuth(keys={"alice": "k-alice"})]),
            ttl_sweep_interval=0,
            expectation_sweep_interval=0,
        )
        await hub.register_identity(
            Passport(name="alice", auth=AuthBlock(scheme="api_key", claim={"token": "k-alice"})),
            Resume(),
        )

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await client.open()
                await client.send_frame(
                    HelloFrame(name="alice", auth_scheme="api_key", auth_claim={"token": "k-alice"})
                )
                frame = await _next_frame(client)
                assert isinstance(frame, WelcomeFrame)
            finally:
                await client.close()
        await hub.close()

    @pytest.mark.asyncio
    async def test_api_key_wrong_token_returns_auth_failed(self) -> None:
        hub = await Hub.open(
            MemoryKnowledgeStore(),
            auth=AuthRegistry([NoAuth(), ApiKeyAuth(keys={"alice": "k-alice"})]),
            ttl_sweep_interval=0,
            expectation_sweep_interval=0,
        )
        passport = await hub.register_identity(
            Passport(name="alice", auth=AuthBlock(scheme="api_key", claim={"token": "k-alice"})),
            Resume(),
        )

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await client.open()
                await client.send_frame(
                    HelloFrame(name="alice", auth_scheme="api_key", auth_claim={"token": "k-wrong"})
                )
                frame = await _next_frame(client)
                assert isinstance(frame, ErrorFrame)
                assert frame.code == "auth_failed"
                # Endpoint MUST NOT be bound on auth failure.
                assert passport.agent_id not in hub._agent_to_endpoint
            finally:
                await client.close()
        await hub.close()


async def _setup_active_channel(hub, channel_type: str = "discussion"):
    """Create an active alice/bob channel via the in-process HubClient
    flow (so invite acks land cleanly), then drop both HubClients so
    follow-on tests can attach raw WS clients without binding conflicts.
    Returns (alice_id, bob_id, channel)."""
    local_link = LocalLink(hub)
    alice_hc = HubClient(local_link, hub=hub)
    bob_hc = HubClient(local_link, hub=hub)
    alice = await alice_hc.register(_agent("alice"), Passport(name="alice"), Resume())
    bob = await bob_hc.register(_agent("bob"), Passport(name="bob"), Resume())
    channel = await alice.open(type=channel_type, target=["bob"])
    await alice_hc.close()
    await bob_hc.close()
    # Give the LocalLink endpoints a tick to drop their bindings via
    # the hub's cleanup-on-handle-exit path.
    await asyncio.sleep(0.05)
    return alice.agent_id, bob.agent_id, channel


class TestEnvelopePostAndDispatch:
    @pytest.mark.asyncio
    async def test_dispatched_envelope_delivers_over_ws(self) -> None:
        """Alice posts a task envelope through the hub; bob — connected
        only via WS — receives it as a NotifyFrame and the receipt he
        sends back advances his cursor."""
        hub = await _new_hub()
        alice_id, bob_id, channel = await _setup_active_channel(hub)

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            bob_ws = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await bob_ws.open()
                await bob_ws.send_frame(HelloFrame(name="bob"))
                welcome = await _next_frame(bob_ws)
                assert isinstance(welcome, WelcomeFrame)
                assert hub._agent_to_endpoint[bob_id] == welcome.endpoint_id

                # Task envelope bypasses the discussion adapter's round-robin.
                missed = Envelope(
                    channel_id=channel.channel_id,
                    sender_id=alice_id,
                    audience=[bob_id],
                    event_type="ag2.task.progress",
                    event_data={"step": "delivered-over-ws"},
                )
                await hub.post_envelope(missed)

                notify = await _next_frame(bob_ws)
                assert isinstance(notify, NotifyFrame)
                assert notify.recipient_id == bob_id
                assert notify.envelope.event_data.get("step") == "delivered-over-ws"

                await bob_ws.send_frame(
                    ReceiptFrame(
                        envelope_id=notify.envelope.envelope_id,
                        status="ack",
                        recipient_id=bob_id,
                        channel_id=channel.channel_id,
                    )
                )
                await asyncio.sleep(0.1)
                assert hub.inbox_cursor(bob_id, channel.channel_id) == notify.envelope.envelope_id
            finally:
                await bob_ws.close()
        await hub.close()

    @pytest.mark.asyncio
    async def test_in_channel_text_send_acks_and_dispatches_in_local_flow(self) -> None:
        """End-to-end text round-trip via the in-process HubClient stack
        keeps working unchanged after the WS surface is wired."""
        hub = await _new_hub()
        local_link = LocalLink(hub)
        alice_hc = HubClient(local_link, hub=hub)
        bob_hc = HubClient(local_link, hub=hub)
        alice = await alice_hc.register(_agent("alice"), Passport(name="alice"), Resume())
        bob = await bob_hc.register(_agent("bob"), Passport(name="bob"), Resume())
        try:
            channel = await alice.open(type="discussion", target=["bob"])
            await channel.send("hello", audience=[bob.agent_id])
            await wait_for_text_count(hub, channel.channel_id, 1)
        finally:
            await alice_hc.close()
            await bob_hc.close()
            await hub.close()


class TestReplayOverWire:
    @pytest.mark.asyncio
    async def test_since_envelope_id_replays_missed_envelopes(self) -> None:
        hub = await _new_hub()
        alice_id, bob_id, channel = await _setup_active_channel(hub)

        # Post a task envelope addressed to bob while bob is offline.
        missed = Envelope(
            channel_id=channel.channel_id,
            sender_id=alice_id,
            audience=[bob_id],
            event_type="ag2.task.progress",
            event_data={"step": "missed-while-offline"},
        )
        await hub.post_envelope(missed)

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await client.open()
                # since_envelope_id="" replays anything past the empty
                # cursor — i.e. everything bob hasn't yet acked.
                await client.send_frame(HelloFrame(name="bob", since_envelope_id=""))
                welcome = await _next_frame(client)
                assert isinstance(welcome, WelcomeFrame)

                seen_missed = False
                deadline = asyncio.get_event_loop().time() + 2.0
                while asyncio.get_event_loop().time() < deadline:
                    try:
                        frame = await asyncio.wait_for(_next_frame(client), 0.5)
                    except TimeoutError:
                        break
                    if (
                        isinstance(frame, NotifyFrame)
                        and frame.envelope.event_data.get("step") == "missed-while-offline"
                    ):
                        seen_missed = True
                        break
                assert seen_missed
            finally:
                await client.close()
        await hub.close()


class TestConnectionLifecycle:
    @pytest.mark.asyncio
    async def test_client_close_drops_server_endpoint(self) -> None:
        hub = await _new_hub()
        passport = await hub.register_identity(Passport(name="alice"), Resume())
        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            client = WsLinkClient(f"ws://127.0.0.1:{port}")
            await client.open()
            await client.send_frame(HelloFrame(name="alice"))
            welcome = await _next_frame(client)
            assert isinstance(welcome, WelcomeFrame)
            assert welcome.endpoint_id in hub._endpoints_by_id
            assert hub._agent_to_endpoint[passport.agent_id] == welcome.endpoint_id

            await client.close()
            await asyncio.sleep(0.2)
            # Endpoint and agent binding both released on disconnect.
            assert welcome.endpoint_id not in hub._endpoints_by_id
            assert passport.agent_id not in hub._agent_to_endpoint
        await hub.close()


class TestTracePropagationOverWire:
    @pytest.mark.asyncio
    async def test_traceparent_survives_ws_dispatch(self) -> None:
        """A substantive envelope's W3C traceparent — stamped by the hub's
        envelope tracer before WAL — survives JSON frame encode/decode and
        arrives intact on a participant connected over a real WebSocket, so a
        cross-process consumer can continue the same trace.

        Task/channel protocol events are deliberately untraced, so only a
        substantive ``EV_TEXT`` send exercises propagation.
        """
        exporter = _CapturingExporter()
        provider = TracerProvider()
        provider.add_span_processor(SimpleSpanProcessor(exporter))

        hub = await Hub.open(
            MemoryKnowledgeStore(),
            tracer_provider=provider,
            ttl_sweep_interval=0,
            expectation_sweep_interval=0,
        )
        alice_id, bob_id, channel = await _setup_active_channel(hub, channel_type="conversation")

        async with serve_ws(hub, "127.0.0.1", 0) as server:
            port = _bound_port(server)
            bob_ws = WsLinkClient(f"ws://127.0.0.1:{port}")
            try:
                await bob_ws.open()
                await bob_ws.send_frame(HelloFrame(name="bob"))
                welcome = await _next_frame(bob_ws)
                assert isinstance(welcome, WelcomeFrame)

                # Alice posts a substantive text envelope; the hub stamps a
                # traceparent onto it before WAL and dispatches it to bob,
                # who is connected only over the WebSocket.
                await hub.post_envelope(
                    Envelope(
                        channel_id=channel.channel_id,
                        sender_id=alice_id,
                        audience=[bob_id],
                        event_type=EV_TEXT,
                        event_data={"text": "traced over the wire"},
                    )
                )

                notify = await _wait_for_notify(bob_ws, "traced over the wire")
                assert notify.recipient_id == bob_id

                # The traceparent survived the JSON wire hop intact.
                wire_traceparent = notify.envelope.trace_id
                assert wire_traceparent, "WS-delivered envelope lost its traceparent"
                assert wire_traceparent.startswith("00-")

                # WAL is the source of truth: persisted copy and WS-delivered
                # copy carry the same traceparent.
                wal = await hub.read_wal(channel.channel_id)
                wal_text = [e for e in wal if e.event_type == EV_TEXT]
                assert wal_text and wal_text[0].trace_id == wire_traceparent

                # That traceparent belongs to the hub's network.envelope span,
                # so a cross-process consumer joins the same trace.
                envelope_spans = [s for s in exporter.spans if s.attributes.get("ag2.span.type") == "envelope"]
                assert envelope_spans, "expected a network.envelope span"
                span_trace_hex = format(envelope_spans[0].context.trace_id, "032x")
                assert wire_traceparent.split("-")[1] == span_trace_hex
            finally:
                await bob_ws.close()
        await hub.close()