Ctrl K
ComunidadGuías…

test/a2a/test_turn_equivalence.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

"""Streaming and polling return the same reply when a server reuses an artifact id across turns.

Every script runs two turns on one task. The first writes artifact ``art`` and requests a client
tool; the continuation, a new turn on the same task, updates ``art`` again and may write a second
artifact ``b``. Updates append, replace with text that extends the content, replace it with other
text, or restate it unchanged, and any artifact may be closed with ``last_chunk`` after its last
update in a turn.

The polling client is shown every intermediate snapshot: after each update the executor waits until
the task store holds it and a ``GetTask`` has read it. So both modes see the same sequence of
contents, and the test can compute the reply and the chunks from the rule alone:

- an artifact's reply is its content past its baseline (its content when the turn began) when it
  extends it, its whole content otherwise;
- the reply joins the non-empty artifact replies in the order the artifacts first appeared on the task;
- a chunk is the reply text not yet sent in this turn, and nothing when the reply no longer starts
  with what was sent, since sent text cannot be retracted.
"""

import asyncio
import json
from dataclasses import dataclass
from enum import Enum

import httpx
import pytest
from a2a.server.context import ServerCallContext
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import Part, Task

from ag2 import Agent
from ag2.a2a import A2AConfig, A2AServer
from ag2.a2a.extension import MIME_TOOL_CALL
from ag2.a2a.mappers.parts import data_part
from ag2.a2a.mappers.tools import call_to_payload
from ag2.events import ModelMessageChunk, ToolCallEvent
from ag2.stream import MemoryStream
from ag2.testing import TestConfig

from ._helpers import EventCollector, ToolRoundTripExecutor

_URL = "http://test"


def step() -> str:
    return "done"


class _Kind(Enum):
    CREATE = "create"
    APPEND = "append"
    EXTEND = "extend"
    OTHER = "other"
    RESTATE = "restate"


_CREATED = {"art": "pre-", "b": "X"}


@dataclass(frozen=True)
class _Op:
    artifact_id: str
    kind: _Kind
    last_chunk: bool = False

    def apply(self, content: str) -> tuple[str, bool, str]:
        """Return the update's text, its ``append`` flag, and the artifact's content after it."""
        tag = self.artifact_id[0]
        if self.kind is _Kind.CREATE:
            return _CREATED[self.artifact_id], False, _CREATED[self.artifact_id]
        if self.kind is _Kind.APPEND:
            return f"+{tag}", True, f"{content}+{tag}"
        if self.kind is _Kind.EXTEND:
            return f"{content}*{tag}", False, f"{content}*{tag}"
        if self.kind is _Kind.OTHER:
            return f"!{tag}", False, f"!{tag}"
        return content, False, content

    @property
    def id(self) -> str:
        return f"{self.artifact_id}.{self.kind.value}{'.last' if self.last_chunk else ''}"


@dataclass(frozen=True)
class _Script:
    first: tuple[_Op, ...]
    second: tuple[_Op, ...]

    @property
    def id(self) -> str:
        return f"{'+'.join(op.id for op in self.first)}|{'+'.join(op.id for op in self.second)}"

    def expected(self) -> tuple[str, list[str]]:
        """The second turn's reply and the chunks of both turns, from the rule alone."""
        content: dict[str, str] = {}
        chunks: list[str] = []
        reply = ""
        for ops in (self.first, self.second):
            baseline = dict(content)
            sent: dict[str, str] = {}
            for op in ops:
                *_, content[op.artifact_id] = op.apply(content.get(op.artifact_id, ""))
                current = _reply(baseline.get(op.artifact_id, ""), content[op.artifact_id])
                previous = sent.get(op.artifact_id, "")
                if current.startswith(previous) and (chunk := current[len(previous) :]):
                    chunks.append(chunk)
                    sent[op.artifact_id] = current
            # Dicts keep insertion order: the order of first appearance on the task.
            reply = "".join(_reply(baseline.get(artifact_id, ""), text) for artifact_id, text in content.items())
        return reply, chunks


def _reply(baseline: str, content: str) -> str:
    return content[len(baseline) :] if content.startswith(baseline) else content


def _closing_variants(ops: tuple[_Op, ...]) -> list[tuple[_Op, ...]]:
    """``ops`` with every combination of ``last_chunk`` on each artifact's last update in the turn."""
    last_index = {op.artifact_id: index for index, op in enumerate(ops)}
    closable = sorted(last_index.values())
    variants = []
    for mask in range(2 ** len(closable)):
        closing = {index for bit, index in enumerate(closable) if mask >> bit & 1}
        variants.append(tuple(_Op(op.artifact_id, op.kind, i in closing) for i, op in enumerate(ops)))
    return variants


def _turns(prefix: tuple[_Op, ...], ids: tuple[str, ...], length: int, seen: frozenset[str]) -> list[tuple[_Op, ...]]:
    """Every sequence of ``length`` updates over ``ids``; an artifact not yet on the task is created first."""
    if length == 0:
        return [prefix]
    sequences = []
    for artifact_id in ids:
        kinds = [_Kind.CREATE] if artifact_id not in seen else [k for k in _Kind if k is not _Kind.CREATE]
        for kind in kinds:
            sequences += _turns((*prefix, _Op(artifact_id, kind)), ids, length - 1, seen | {artifact_id})
    return sequences


def _scripts() -> list[_Script]:
    """Up to 3 updates over 2 turns: ``art`` is created in the first and reused in the second, ``b`` is new."""
    scripts = []
    for first_length, second_length in ((1, 1), (1, 2), (2, 1)):
        for first in _turns((), ("art",), first_length, frozenset()):
            for second in _turns((), ("art", "b"), second_length, frozenset({"art"})):
                scripts += [
                    _Script(first_variant, second_variant)
                    for first_variant in _closing_variants(first)
                    for second_variant in _closing_variants(second)
                ]
    return scripts


_SCRIPTS = _scripts()


class _SavedStore(InMemoryTaskStore):
    """Task store that lets a waiter know when it holds an artifact with a given text."""

    def __init__(self) -> None:
        super().__init__()
        self._changed = asyncio.Condition()
        self._artifacts: dict[str, str] = {}

    async def save(self, task: Task, context: ServerCallContext) -> None:
        await super().save(task, context)
        async with self._changed:
            self._artifacts = {a.artifact_id: "".join(p.text for p in a.parts) for a in task.artifacts}
            self._changed.notify_all()

    async def holds(self, artifact_id: str, text: str) -> None:
        async with self._changed:
            await self._changed.wait_for(lambda: self._artifacts.get(artifact_id) == text)


class _PollGate(httpx.AsyncBaseTransport):
    """In-process transport that tells a waiter once a ``GetTask`` sent after it began waiting has been answered."""

    def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
        self._transport = transport
        self._waiters: list[asyncio.Future[None]] = []

    async def next_poll(self) -> None:
        waiter = asyncio.get_running_loop().create_future()
        self._waiters.append(waiter)
        await waiter

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        waiters: list[asyncio.Future[None]] = []
        if json.loads(request.content or b"{}").get("method") == "GetTask":
            waiters, self._waiters = self._waiters, []
        response = await self._transport.handle_async_request(request)
        await response.aread()
        for waiter in waiters:
            waiter.set_result(None)
        return response


class _ScriptedTurns:
    """Plays a script's turns; when ``gate`` is set, waits for the polling client to read each update."""

    def __init__(self, script: _Script, store: _SavedStore) -> None:
        self._script = script
        self._store = store
        self._content: dict[str, str] = {}
        self.gate: _PollGate | None = None

    async def first_turn(self, updater: TaskUpdater) -> None:
        await self._play(updater, self._script.first)
        call = data_part(call_to_payload(ToolCallEvent(name="step", arguments="{}")), media_type=MIME_TOOL_CALL)
        await updater.add_artifact(parts=[call], artifact_id="call", last_chunk=True)
        await updater.requires_input()

    async def continuation(self, updater: TaskUpdater) -> None:
        await self._play(updater, self._script.second)
        await updater.complete()

    async def _play(self, updater: TaskUpdater, ops: tuple[_Op, ...]) -> None:
        for op in ops:
            text, append, content = op.apply(self._content.get(op.artifact_id, ""))
            self._content[op.artifact_id] = content
            await updater.add_artifact(
                parts=[Part(text=text)],
                artifact_id=op.artifact_id,
                append=append or None,
                last_chunk=op.last_chunk or None,
            )
            if self.gate is not None:
                await self._store.holds(op.artifact_id, content)
                await self.gate.next_poll()


async def _ask(script: _Script, *, streaming: bool) -> tuple[str | None, list[str]]:
    store = _SavedStore()
    turns = _ScriptedTurns(script, store)
    executor = ToolRoundTripExecutor(turns.first_turn, turns.continuation)
    server = A2AServer(Agent("server-stub", config=TestConfig("unused")), executor=executor, task_store=store)
    gate = _PollGate(httpx.ASGITransport(app=server.build_jsonrpc(url=_URL)))
    if not streaming:
        turns.gate = gate
    config = A2AConfig(
        card_url=_URL,
        httpx_client_factory=lambda: httpx.AsyncClient(transport=gate, base_url=_URL, timeout=30.0),
        streaming=streaming,
        polling_interval=0.001,
    )
    client = Agent("client", config=config, tools=[step])
    stream = MemoryStream()
    collector = EventCollector()
    stream.where(ModelMessageChunk).subscribe(collector.on_event)

    reply = await asyncio.wait_for(client.ask("go", stream=stream), timeout=10.0)

    return reply.body, collector.chunks


@pytest.mark.asyncio
@pytest.mark.parametrize("script", _SCRIPTS, ids=[script.id for script in _SCRIPTS])
async def test_streaming_and_polling_follow_the_reply_rule(script: _Script) -> None:
    expected_reply, expected_chunks = script.expected()

    streamed = await _ask(script, streaming=True)
    polled = await _ask(script, streaming=False)

    assert polled == streamed
    assert streamed == (expected_reply or None, expected_chunks)