From de949106d300842b24fb0cd9fb27c10227cf802c Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 22 Jul 2025 00:40:53 +0000 Subject: [PATCH 1/9] Add streaming infrastructure to broker layer - Add abstract streaming methods to Broker base class: - send_stream_event() for publishing events - subscribe_to_stream() for receiving events - Implement pub-sub pattern in InMemoryBroker: - Track subscribers with _event_subscribers dict - Use anyio.Lock for thread-safe subscription management - Handle automatic cleanup of disconnected subscribers - Support final status updates to complete streams - Add StreamEvent type union and TypeAdapter to schema Co-Authored-By: Claude --- fasta2a/broker.py | 100 ++++++++++++++++++++++++++++++++++++++++++++-- fasta2a/schema.py | 4 ++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/fasta2a/broker.py b/fasta2a/broker.py index c84b738..e535500 100644 --- a/fasta2a/broker.py +++ b/fasta2a/broker.py @@ -4,17 +4,21 @@ from collections.abc import AsyncIterator from contextlib import AsyncExitStack from dataclasses import dataclass -from typing import Annotated, Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar, Union import anyio +from anyio.streams.memory import MemoryObjectSendStream from opentelemetry.trace import Span, get_current_span, get_tracer from pydantic import Discriminator from typing_extensions import Self, TypedDict -from .schema import TaskIdParams, TaskSendParams +from .schema import Message, Task, TaskArtifactUpdateEvent, TaskIdParams, TaskSendParams, TaskStatusUpdateEvent tracer = get_tracer(__name__) +StreamEvent = Union[Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent] +"""Type alias for all events that can be streamed.""" + @dataclass class Broker(ABC): @@ -30,12 +34,32 @@ class Broker(ABC): @abstractmethod async def run_task(self, params: TaskSendParams) -> None: """Send a task to be executed by the worker.""" - raise NotImplementedError('send_run_task is not implemented yet.') + ... @abstractmethod async def cancel_task(self, params: TaskIdParams) -> None: """Cancel a task.""" - raise NotImplementedError('send_cancel_task is not implemented yet.') + ... + + @abstractmethod + async def send_stream_event(self, task_id: str, event: StreamEvent) -> None: + """Send a streaming event from worker to subscribers. + + This is used by workers to publish status updates, messages, and artifacts + during task execution. Events are forwarded to all active subscribers of + the given task_id. + """ + ... + + @abstractmethod + def subscribe_to_stream(self, task_id: str) -> AsyncIterator[StreamEvent]: + """Subscribe to streaming events for a specific task. + + Returns an async iterator that yields events published by workers for the + given task_id. The iterator completes when a TaskStatusUpdateEvent with + final=True is received or the subscription is cancelled. + """ + ... @abstractmethod async def __aenter__(self) -> Self: ... @@ -73,6 +97,10 @@ class _TaskOperation(TypedDict, Generic[OperationT, ParamsT]): class InMemoryBroker(Broker): """A broker that schedules tasks in memory.""" + def __init__(self): + self._event_subscribers: dict[str, list[MemoryObjectSendStream[StreamEvent]]] = {} + self._subscriber_lock: anyio.Lock | None = None + async def __aenter__(self): self.aexit_stack = AsyncExitStack() await self.aexit_stack.__aenter__() @@ -81,6 +109,8 @@ async def __aenter__(self): await self.aexit_stack.enter_async_context(self._read_stream) await self.aexit_stack.enter_async_context(self._write_stream) + self._subscriber_lock = anyio.Lock() + return self async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any): @@ -96,3 +126,65 @@ async def receive_task_operations(self) -> AsyncIterator[TaskOperation]: """Receive task operations from the broker.""" async for task_operation in self._read_stream: yield task_operation + + async def send_stream_event(self, task_id: str, event: StreamEvent) -> None: + """Send a streaming event from worker to subscribers.""" + assert self._subscriber_lock is not None, 'Broker not initialized' + + async with self._subscriber_lock: + subscribers = self._event_subscribers.get(task_id, []) + if not subscribers: + return + + # Send event to all subscribers, removing closed streams + active_subscribers: list[MemoryObjectSendStream[StreamEvent]] = [] + for stream in subscribers: + try: + await stream.send(event) + active_subscribers.append(stream) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + # Subscriber disconnected, remove from list + pass + + # Update subscriber list with only active ones + if active_subscribers: + self._event_subscribers[task_id] = active_subscribers + else: + # No active subscribers left, clean up + del self._event_subscribers[task_id] + + async def subscribe_to_stream(self, task_id: str) -> AsyncIterator[StreamEvent]: + """Subscribe to streaming events for a specific task.""" + assert self._subscriber_lock is not None, 'Broker not initialized' + + # Create a new stream for this subscriber + send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=100) + + # Register the subscriber + async with self._subscriber_lock: + if task_id not in self._event_subscribers: + self._event_subscribers[task_id] = [] + self._event_subscribers[task_id].append(send_stream) + + try: + async with receive_stream: + async for event in receive_stream: + yield event + + # Check if this is a final status update + if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final', False): + break + finally: + # Clean up subscription on exit + async with self._subscriber_lock: + if task_id in self._event_subscribers: + try: + self._event_subscribers[task_id].remove(send_stream) + if not self._event_subscribers[task_id]: + del self._event_subscribers[task_id] + except ValueError: + # Already removed + pass + + # Close the send stream + await send_stream.aclose() diff --git a/fasta2a/schema.py b/fasta2a/schema.py index f500be4..d1bdbbd 100644 --- a/fasta2a/schema.py +++ b/fasta2a/schema.py @@ -797,3 +797,7 @@ class JSONRPCResponse(JSONRPCMessage, Generic[ResultT, ErrorT]): send_message_response_ta: TypeAdapter[SendMessageResponse] = TypeAdapter(SendMessageResponse) stream_message_request_ta: TypeAdapter[StreamMessageRequest] = TypeAdapter(StreamMessageRequest) stream_message_response_ta: TypeAdapter[StreamMessageResponse] = TypeAdapter(StreamMessageResponse) + +# Type adapter for streaming events +StreamEvent = Union[Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent] +stream_event_ta: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent) From 8e33fefcfbbe4e29d08eff1b62127061c510e7c2 Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 22 Jul 2025 00:41:21 +0000 Subject: [PATCH 2/9] Update task manager to support streaming messages - Rewrite stream_message to return AsyncGenerator[StreamEvent, None] - Implement streaming workflow: - Create and submit task, yielding it immediately - Start task execution asynchronously in background - Subscribe to broker event stream and forward all events - Handle context_id generation and history_length parameter - Clean up worker imports for better organization --- fasta2a/task_manager.py | 42 ++++++++++++++++++++++++++++++++++++----- fasta2a/worker.py | 7 ++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/fasta2a/task_manager.py b/fasta2a/task_manager.py index df06e62..931e597 100644 --- a/fasta2a/task_manager.py +++ b/fasta2a/task_manager.py @@ -60,12 +60,14 @@ from __future__ import annotations as _annotations +import asyncio import uuid +from collections.abc import AsyncGenerator from contextlib import AsyncExitStack from dataclasses import dataclass, field from typing import Any -from .broker import Broker +from .broker import Broker, StreamEvent from .schema import ( CancelTaskRequest, CancelTaskResponse, @@ -79,7 +81,6 @@ SetTaskPushNotificationRequest, SetTaskPushNotificationResponse, StreamMessageRequest, - StreamMessageResponse, TaskNotFoundError, TaskSendParams, ) @@ -156,9 +157,40 @@ async def cancel_task(self, request: CancelTaskRequest) -> CancelTaskResponse: ) return CancelTaskResponse(jsonrpc='2.0', id=request['id'], result=task) - async def stream_message(self, request: StreamMessageRequest) -> StreamMessageResponse: - """Stream messages using Server-Sent Events.""" - raise NotImplementedError('message/stream method is not implemented yet.') + async def stream_message(self, request: StreamMessageRequest) -> AsyncGenerator[StreamEvent, None]: + """Handle a streaming message request. + + This method: + 1. Creates and submits a new task + 2. Yields the initial task object + 3. Subscribes to the broker's event stream + 4. Starts task execution asynchronously + 5. Streams all events until completion + """ + # Extract parameters + params = request['params'] + message = params['message'] + context_id = message.get('context_id', str(uuid.uuid4())) + + # Create and submit the task + task = await self.storage.submit_task(context_id, message) + + # Yield the initial task + yield task + + # Prepare broker params + broker_params: TaskSendParams = {'id': task['id'], 'context_id': context_id, 'message': message} + config = params.get('configuration', {}) + history_length = config.get('history_length') + if history_length is not None: + broker_params['history_length'] = history_length + + # Start task execution in background + asyncio.create_task(self.broker.run_task(broker_params)) + + # Stream events from broker + async for event in self.broker.subscribe_to_stream(task['id']): + yield event async def set_task_push_notification( self, request: SetTaskPushNotificationRequest diff --git a/fasta2a/worker.py b/fasta2a/worker.py index bcb0172..9127f10 100644 --- a/fasta2a/worker.py +++ b/fasta2a/worker.py @@ -14,7 +14,12 @@ if TYPE_CHECKING: from .broker import Broker, TaskOperation - from .schema import Artifact, Message, TaskIdParams, TaskSendParams + from .schema import ( + Artifact, + Message, + TaskIdParams, + TaskSendParams, + ) tracer = get_tracer(__name__) From e78950c56536f31c03a0e8cdbb4835cc63b3ce29 Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 22 Jul 2025 00:41:47 +0000 Subject: [PATCH 3/9] Add SSE endpoint for streaming messages - Add streaming parameter to FastA2A constructor - Implement message/stream handler that: - Parses streaming requests with proper validation - Wraps task manager events in JSON-RPC responses - Returns Server-Sent Events (SSE) response - Update agent capabilities to advertise streaming support - Use camelCase serialization for protocol compliance --- fasta2a/applications.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/fasta2a/applications.py b/fasta2a/applications.py index c5a5b5d..2da742b 100644 --- a/fasta2a/applications.py +++ b/fasta2a/applications.py @@ -1,10 +1,12 @@ from __future__ import annotations as _annotations +import json from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from pathlib import Path from typing import Any +from sse_starlette import EventSourceResponse from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request @@ -21,6 +23,8 @@ a2a_request_ta, a2a_response_ta, agent_card_ta, + stream_event_ta, + stream_message_request_ta, ) from .storage import Storage from .task_manager import TaskManager @@ -41,6 +45,7 @@ def __init__( description: str | None = None, provider: AgentProvider | None = None, skills: list[Skill] | None = None, + streaming: bool = False, # Starlette debug: bool = False, routes: Sequence[Route] | None = None, @@ -65,6 +70,7 @@ def __init__( self.description = description self.provider = provider self.skills = skills or [] + self.streaming = streaming # NOTE: For now, I don't think there's any reason to support any other input/output modes. self.default_input_modes = ['application/json'] self.default_output_modes = ['application/json'] @@ -94,7 +100,7 @@ async def _agent_card_endpoint(self, request: Request) -> Response: default_input_modes=self.default_input_modes, default_output_modes=self.default_output_modes, capabilities=AgentCapabilities( - streaming=False, push_notifications=False, state_transition_history=False + streaming=self.streaming, push_notifications=False, state_transition_history=False ), ) if self.provider is not None: @@ -125,6 +131,25 @@ async def _agent_run_endpoint(self, request: Request) -> Response: if a2a_request['method'] == 'message/send': jsonrpc_response = await self.task_manager.send_message(a2a_request) + elif a2a_request['method'] == 'message/stream': + # Parse the streaming request + stream_request = stream_message_request_ta.validate_json(data) + + # Create an async generator wrapper that formats events as JSON-RPC responses + async def sse_generator(): + request_id = stream_request.get('id') + async for event in self.task_manager.stream_message(stream_request): + # Serialize event to ensure proper camelCase conversion + event_dict = stream_event_ta.dump_python(event, mode='json', by_alias=True) + + # Wrap in JSON-RPC response + jsonrpc_response = {'jsonrpc': '2.0', 'id': request_id, 'result': event_dict} + + # Convert to JSON string + yield json.dumps(jsonrpc_response) + + # Return SSE response + return EventSourceResponse(sse_generator()) elif a2a_request['method'] == 'tasks/get': jsonrpc_response = await self.task_manager.get_task(a2a_request) elif a2a_request['method'] == 'tasks/cancel': From 8c825e0da06efc1741da1ab391cb4a467567b5b5 Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 22 Jul 2025 00:42:08 +0000 Subject: [PATCH 4/9] Add streaming dependencies and configuration - Add sse-starlette>=2.0.0 for Server-Sent Events support - Add dev dependencies: - httpx-sse for testing SSE functionality - pytest-asyncio for async test support - Add comprehensive coverage configuration: - Enable branch coverage - Configure source paths and omit patterns - Add exclusion patterns for abstract methods and TYPE_CHECKING These dependencies enable the streaming implementation and improve the development/testing experience. --- pyproject.toml | 24 ++++++++++++++++++++++++ uv.lock | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2af3b7d..ee4a489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "pydantic>=2.10", "opentelemetry-api>=1.28.0", "eval_type_backport>=0.2.2; python_version <= '3.9'", + "sse-starlette>=2.0.0", ] [project.optional-dependencies] @@ -58,8 +59,10 @@ dev = [ "asgi-lifespan", "coverage", "httpx", + "httpx-sse", "inline-snapshot", "pytest", + "pytest-asyncio", "ruff", "pyright", ] @@ -120,3 +123,24 @@ include = [ "fasta2a", "tests", ] + +[tool.coverage.run] +branch = true +source = ["fasta2a"] +omit = ["fasta2a/static/*"] + +[tool.coverage.report] +skip_covered = true +show_missing = true +precision = 2 +exclude_lines = [ + # Standard marker for code that's not covered + 'pragma: no cover', + # Don't complain about abstract methods and ellipsis + '@abstractmethod', + '^\s*\.\.\.\s*$', + # Don't complain about TYPE_CHECKING blocks + 'if TYPE_CHECKING:', + # Don't complain about NotImplementedError + 'raise NotImplementedError', +] diff --git a/uv.lock b/uv.lock index a77fc88..4abacf7 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "backrefs" version = "5.9" @@ -439,6 +448,7 @@ dependencies = [ { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, { name = "opentelemetry-api" }, { name = "pydantic" }, + { name = "sse-starlette" }, { name = "starlette" }, ] @@ -452,9 +462,11 @@ dev = [ { name = "asgi-lifespan" }, { name = "coverage" }, { name = "httpx" }, + { name = "httpx-sse" }, { name = "inline-snapshot" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] docs = [ @@ -470,6 +482,7 @@ requires-dist = [ { name = "logfire", marker = "extra == 'logfire'", specifier = ">=2.3" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.10" }, + { name = "sse-starlette", specifier = ">=2.0.0" }, { name = "starlette", specifier = ">0.29.0" }, ] provides-extras = ["logfire"] @@ -479,9 +492,11 @@ dev = [ { name = "asgi-lifespan" }, { name = "coverage" }, { name = "httpx" }, + { name = "httpx-sse" }, { name = "inline-snapshot" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] docs = [ @@ -564,6 +579,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1356,6 +1380,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1514,6 +1552,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" }, +] + [[package]] name = "starlette" version = "0.47.1" From 79362efed2fc368781da47e3d8cc940b290cbb8e Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 22 Jul 2025 00:42:32 +0000 Subject: [PATCH 5/9] Add tests for agent card functionality - Test agent card with all configuration parameters - Test HTTP HEAD and OPTIONS methods support - Test caching behavior with ETag and Last-Modified headers - Test documentation endpoint functionality These tests ensure the agent card endpoints behave correctly according to the A2A protocol specification. --- tests/test_applications.py | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_applications.py b/tests/test_applications.py index 6c7440d..71a8eea 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -44,3 +44,72 @@ async def test_agent_card(): }, } ) + + +async def test_agent_card_with_all_params(): + """Test agent card with all parameters specified.""" + app = FastA2A( + storage=InMemoryStorage(), + broker=InMemoryBroker(), + name='Test Agent', + url='https://example.com', + version='2.0.0', + description='A test agent', + provider='Test Provider', + skills=['skill1', 'skill2'], + streaming=True, + ) + async with create_test_client(app) as client: + response = await client.get('/.well-known/agent.json') + assert response.status_code == 200 + data = response.json() + assert data['name'] == 'Test Agent' + assert data['url'] == 'https://example.com' + assert data['version'] == '2.0.0' + assert data['description'] == 'A test agent' + assert data['provider'] == 'Test Provider' + assert data['skills'] == ['skill1', 'skill2'] + assert data['capabilities']['streaming'] is True + + +async def test_agent_card_head_and_options(): + """Test HEAD and OPTIONS methods for agent card.""" + app = FastA2A(storage=InMemoryStorage(), broker=InMemoryBroker()) + async with create_test_client(app) as client: + # Test HEAD + head_response = await client.head('/.well-known/agent.json') + assert head_response.status_code == 200 + + # Test OPTIONS + options_response = await client.options('/.well-known/agent.json') + assert options_response.status_code == 200 + + +async def test_agent_card_caching(): + """Test that agent card is cached after first generation.""" + app = FastA2A(storage=InMemoryStorage(), broker=InMemoryBroker(), name='Original') + async with create_test_client(app) as client: + # First request + response1 = await client.get('/.well-known/agent.json') + assert response1.status_code == 200 + data1 = response1.json() + assert data1['name'] == 'Original' + + # Modify app (shouldn't affect cached response) + app.name = 'Modified' + + # Second request should return cached + response2 = await client.get('/.well-known/agent.json') + assert response2.status_code == 200 + data2 = response2.json() + assert data2['name'] == 'Original' + + +async def test_docs_endpoint(): + """Test the /docs endpoint.""" + app = FastA2A(storage=InMemoryStorage(), broker=InMemoryBroker()) + async with create_test_client(app) as client: + response = await client.get('/docs') + assert response.status_code == 200 + assert response.headers['content-type'] == 'text/html; charset=utf-8' + assert b'' in response.content or b' Date: Tue, 22 Jul 2025 00:47:35 +0000 Subject: [PATCH 6/9] Add comprehensive tests for streaming functionality - Add test_streaming_integration.py with end-to-end streaming tests - Add test_broker.py to test pub-sub functionality and edge cases - Add test_task_manager.py to test streaming message handling - Test concurrent subscribers, disconnection handling, error cases - Verify SSE response format and JSON-RPC compliance --- tests/test_broker.py | 474 ++++++++++++++++++++++++++++ tests/test_streaming_integration.py | 447 ++++++++++++++++++++++++++ tests/test_task_manager.py | 438 +++++++++++++++++++++++++ 3 files changed, 1359 insertions(+) create mode 100644 tests/test_broker.py create mode 100644 tests/test_streaming_integration.py create mode 100644 tests/test_task_manager.py diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..ef64704 --- /dev/null +++ b/tests/test_broker.py @@ -0,0 +1,474 @@ +"""Tests for the broker pub/sub functionality.""" + +import asyncio + +import anyio +import pytest + +from fasta2a.broker import InMemoryBroker, StreamEvent +from fasta2a.schema import Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent + + +@pytest.mark.asyncio +async def test_broker_pub_sub_single_subscriber(): + """Test basic pub/sub with a single subscriber.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-123' + events_received = [] + + # Create a task to track completion + subscriber_done = asyncio.Event() + + async def subscriber(): + async for event in broker.subscribe_to_stream(task_id): + events_received.append(event) + # Check for final event + if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + break + subscriber_done.set() + + # Start subscriber in background + subscriber_task = asyncio.create_task(subscriber()) + + # Give subscriber time to set up + await asyncio.sleep(0.1) + + # Send some events + test_task: Task = { + 'id': task_id, + 'context_id': 'test-context', + 'kind': 'task', + 'status': {'state': 'submitted'}, + } + await broker.send_stream_event(task_id, test_task) + + status_update: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': 'working'}, + 'final': False, + } + await broker.send_stream_event(task_id, status_update) + + final_update: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': 'completed'}, + 'final': True, + } + await broker.send_stream_event(task_id, final_update) + + # Wait for subscriber to complete + await subscriber_done.wait() + await subscriber_task + + # Verify events were received + assert len(events_received) == 3 + assert events_received[0] == test_task + assert events_received[1] == status_update + assert events_received[2] == final_update + + +@pytest.mark.asyncio +async def test_broker_pub_sub_multiple_subscribers(): + """Test pub/sub with multiple subscribers to the same task.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-456' + events_sub1 = [] + events_sub2 = [] + + # Create completion events + sub1_done = asyncio.Event() + sub2_done = asyncio.Event() + + async def subscriber1(): + async for event in broker.subscribe_to_stream(task_id): + events_sub1.append(event) + if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + break + sub1_done.set() + + async def subscriber2(): + async for event in broker.subscribe_to_stream(task_id): + events_sub2.append(event) + if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + break + sub2_done.set() + + # Start both subscribers + sub1_task = asyncio.create_task(subscriber1()) + sub2_task = asyncio.create_task(subscriber2()) + + # Give subscribers time to set up + await asyncio.sleep(0.1) + + # Send event + test_event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': 'completed'}, + 'final': True, + } + await broker.send_stream_event(task_id, test_event) + + # Wait for both subscribers + await sub1_done.wait() + await sub2_done.wait() + await sub1_task + await sub2_task + + # Both should receive the event + assert len(events_sub1) == 1 + assert len(events_sub2) == 1 + assert events_sub1[0] == test_event + assert events_sub2[0] == test_event + + +@pytest.mark.asyncio +async def test_broker_no_subscribers(): + """Test sending events when there are no subscribers.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-789' + + # This should not raise an error even with no subscribers + test_event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': 'working'}, + 'final': False, + } + await broker.send_stream_event(task_id, test_event) + + # Verify no subscribers exist + assert task_id not in broker._event_subscribers + + +@pytest.mark.asyncio +async def test_broker_subscriber_cleanup_on_disconnect(): + """Test that disconnected subscribers are automatically cleaned up.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-cleanup' + + # Create a subscriber that exits early + async def early_exit_subscriber(): + async for event in broker.subscribe_to_stream(task_id): + # Exit after first event + break + + # Start subscriber + subscriber_task = asyncio.create_task(early_exit_subscriber()) + + # Give subscriber time to set up + await asyncio.sleep(0.1) + + # Verify subscriber is registered + assert task_id in broker._event_subscribers + assert len(broker._event_subscribers[task_id]) == 1 + + # Send first event + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'working'}, + 'final': False, + }, + ) + + # Wait for subscriber to exit + await subscriber_task + + # Give time for cleanup to happen + await asyncio.sleep(0.1) + + # Add a second subscriber to verify cleanup happens during send + events_received = [] + complete = asyncio.Event() + + async def second_subscriber(): + async for event in broker.subscribe_to_stream(task_id): + events_received.append(event) + if isinstance(event, dict) and event.get('final'): + break + complete.set() + + sub2_task = asyncio.create_task(second_subscriber()) + await asyncio.sleep(0.1) + + # Now send another event - the first (disconnected) subscriber should be cleaned up + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'completed'}, + 'final': True, + }, + ) + + # Wait for second subscriber + await complete.wait() + await sub2_task + + # Verify the second subscriber got the event + assert len(events_received) == 1 + assert events_received[0]['status']['state'] == 'completed' + + +@pytest.mark.asyncio +async def test_broker_artifact_streaming(): + """Test streaming artifact update events.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-artifacts' + events_received = [] + + complete = asyncio.Event() + + async def subscriber(): + async for event in broker.subscribe_to_stream(task_id): + events_received.append(event) + if isinstance(event, dict) and event.get('kind') == 'artifact-update' and event.get('last_chunk'): + break + complete.set() + + # Start subscriber + subscriber_task = asyncio.create_task(subscriber()) + await asyncio.sleep(0.1) + + # Send artifact updates + artifact1: TaskArtifactUpdateEvent = { + 'kind': 'artifact-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'artifact': {'artifact_id': 'artifact-1', 'parts': [{'kind': 'text', 'text': 'Hello'}]}, + 'append': False, + } + await broker.send_stream_event(task_id, artifact1) + + artifact2: TaskArtifactUpdateEvent = { + 'kind': 'artifact-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'artifact': {'artifact_id': 'artifact-1', 'parts': [{'kind': 'text', 'text': ' World'}]}, + 'append': True, + 'last_chunk': True, + } + await broker.send_stream_event(task_id, artifact2) + + # Wait for completion + await complete.wait() + await subscriber_task + + # Verify both artifacts received + assert len(events_received) == 2 + assert events_received[0]['artifact']['parts'][0]['text'] == 'Hello' + assert events_received[1]['artifact']['parts'][0]['text'] == ' World' + assert events_received[1]['append'] is True + assert events_received[1]['last_chunk'] is True + + +@pytest.mark.asyncio +async def test_broker_concurrent_operations(): + """Test concurrent pub/sub operations.""" + async with InMemoryBroker() as broker: + num_tasks = 5 + num_events_per_task = 10 + + results = {f'task-{i}': [] for i in range(num_tasks)} + + async def subscriber(task_id: str): + async for event in broker.subscribe_to_stream(task_id): + results[task_id].append(event) + if isinstance(event, dict) and event.get('final'): + break + + async def publisher(task_id: str): + for i in range(num_events_per_task - 1): + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'working'}, + 'message_num': i, + 'final': False, + }, + ) + await asyncio.sleep(0.01) # Small delay to test ordering + + # Send final event + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'completed'}, + 'message_num': num_events_per_task - 1, + 'final': True, + }, + ) + + # Start all subscribers and publishers concurrently + async with anyio.create_task_group() as tg: + for i in range(num_tasks): + task_id = f'task-{i}' + tg.start_soon(subscriber, task_id) + tg.start_soon(publisher, task_id) + + # Verify all events were received in order + for i in range(num_tasks): + task_id = f'task-{i}' + assert len(results[task_id]) == num_events_per_task + for j in range(num_events_per_task): + assert results[task_id][j]['message_num'] == j + + +@pytest.mark.asyncio +async def test_broker_closed_stream_handling(): + """Test handling of closed streams when sending events.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-closed' + + # Create a stream and immediately close the receive side + send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=10) + + # Manually add the send stream to subscribers + async with broker._subscriber_lock: + broker._event_subscribers[task_id] = [send_stream] + + # Close the receive stream to simulate disconnection + await receive_stream.aclose() + + # Now try to send an event - should handle the closed stream gracefully + test_event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': 'working'}, + 'final': False, + } + + # This should not raise an error + await broker.send_stream_event(task_id, test_event) + + # Verify the closed stream was removed + assert task_id not in broker._event_subscribers + + +@pytest.mark.asyncio +async def test_broker_early_final_event(): + """Test that subscription stops on receiving a final event.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-early-final' + events_received = [] + + async def subscriber(): + async for event in broker.subscribe_to_stream(task_id): + events_received.append(event) + + # Start subscriber + subscriber_task = asyncio.create_task(subscriber()) + await asyncio.sleep(0.1) + + # Send a non-final event + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'working'}, + 'final': False, + }, + ) + + # Send a final event - this should cause the subscriber to exit + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'completed'}, + 'final': True, + }, + ) + + # Wait for subscriber to complete + await subscriber_task + + # Send another event after subscriber has exited - no subscribers should receive this + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'ctx', + 'status': {'state': 'working'}, + 'final': False, + }, + ) + + # Should have received only 2 events (not the third) + assert len(events_received) == 2 + assert events_received[1]['final'] is True + + +@pytest.mark.asyncio +async def test_broker_subscriber_double_removal(): + """Test edge case of trying to remove a subscriber twice.""" + async with InMemoryBroker() as broker: + task_id = 'test-task-double-remove' + + # Create and manually manage a subscriber + send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=10) + + # Add subscriber + async with broker._subscriber_lock: + broker._event_subscribers[task_id] = [send_stream] + + # Remove it once + async with broker._subscriber_lock: + broker._event_subscribers[task_id].remove(send_stream) + if not broker._event_subscribers[task_id]: + del broker._event_subscribers[task_id] + + # Add it back to test the ValueError path + async with broker._subscriber_lock: + broker._event_subscribers[task_id] = [send_stream] + + # Now use subscribe_to_stream to create a proper subscription + events = [] + + async def test_subscriber(): + async for event in broker.subscribe_to_stream(task_id): + events.append(event) + # Exit after first event to trigger cleanup + break + + # Start subscriber + sub_task = asyncio.create_task(test_subscriber()) + await asyncio.sleep(0.1) + + # Manually remove the send_stream we added (not the one from subscribe_to_stream) + async with broker._subscriber_lock: + broker._event_subscribers[task_id].remove(send_stream) + + # Send event to trigger the subscriber to exit and cleanup + await broker.send_stream_event(task_id, {'kind': 'test', 'data': 'test'}) + + # Wait for subscriber + await sub_task + + # Clean up + await send_stream.aclose() + await receive_stream.aclose() diff --git a/tests/test_streaming_integration.py b/tests/test_streaming_integration.py new file mode 100644 index 0000000..89525f0 --- /dev/null +++ b/tests/test_streaming_integration.py @@ -0,0 +1,447 @@ +"""Integration tests for the streaming endpoint.""" + +import asyncio +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import httpx +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from httpx_sse import aconnect_sse +from sse_starlette.sse import AppStatus + +from fasta2a import FastA2A, Worker +from fasta2a.broker import InMemoryBroker +from fasta2a.schema import Artifact, Message, TaskIdParams, TaskSendParams +from fasta2a.storage import InMemoryStorage + +Context = list[Message] +"""The shape of the context you store in the storage.""" + + +@pytest.fixture(autouse=True) +def reset_sse_app_status(): + """Reset SSE global state between tests.""" + AppStatus.should_exit_event = None + yield + AppStatus.should_exit_event = None + + +class StreamingWorker(Worker[Context]): + """A test worker that emits streaming events.""" + + async def run_task(self, params: TaskSendParams) -> None: + task_id = params['id'] + context_id = params['context_id'] + + # Load the task + task = await self.storage.load_task(task_id) + assert task is not None + + # Update status to working + await self.storage.update_task(task_id, state='working') + + # Emit initial status update + await self.broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': context_id, + 'status': {'state': 'working'}, + 'final': False, + }, + ) + + # Simulate some work with incremental updates + result_parts = ['Hello', ' from', ' streaming', ' worker!'] + + for i, part in enumerate(result_parts): + # Create an artifact part + artifact: Artifact = {'artifact_id': 'result-1', 'parts': [{'kind': 'text', 'text': part}]} + + # Emit artifact update + await self.broker.send_stream_event( + task_id, + { + 'kind': 'artifact-update', + 'task_id': task_id, + 'context_id': context_id, + 'artifact': artifact, + 'append': i > 0, # Append after first part + 'last_chunk': i == len(result_parts) - 1, + }, + ) + + # Small delay to simulate processing + await asyncio.sleep(0.05) + + # Store the complete artifact + complete_artifact: Artifact = { + 'artifact_id': 'result-1', + 'parts': [{'kind': 'text', 'text': 'Hello from streaming worker!'}], + } + + # Update task with final status + await self.storage.update_task(task_id, state='completed', new_artifacts=[complete_artifact]) + + # Emit final status update + await self.broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': context_id, + 'status': {'state': 'completed'}, + 'final': True, + }, + ) + + async def cancel_task(self, params: TaskIdParams) -> None: + await self.storage.update_task(params['id'], state='canceled') + + def build_message_history(self, history: list[Message]) -> list[Any]: + return history + + def build_artifacts(self, result: Any) -> list[Artifact]: + return [] + + +@pytest_asyncio.fixture(scope='function') +async def streaming_app(): + """Create a FastA2A app with streaming enabled and a streaming worker.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + worker = StreamingWorker(storage=storage, broker=broker) + + @asynccontextmanager + async def lifespan(app: FastA2A) -> AsyncIterator[None]: + async with app.task_manager: + async with worker.run(): + yield + + app = FastA2A( + storage=storage, + broker=broker, + streaming=True, # Enable streaming + lifespan=lifespan, + ) + + return app + + +# ===== Basic Streaming Functionality ===== + + +@pytest.mark.asyncio +async def test_streaming_endpoint_basic(streaming_app): + """Test basic streaming functionality.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + # Send a streaming request + request_data = { + 'jsonrpc': '2.0', + 'id': 'test-1', + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Test streaming'}], + 'messageId': 'msg-1', + 'kind': 'message', + } + }, + } + + events_received = [] + + # Make streaming request using httpx-sse + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + # Collect all events + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + events_received.append(event_data) + + # Verify we received events + assert len(events_received) > 0 + + # First event should be the task + first_event = events_received[0] + assert first_event['jsonrpc'] == '2.0' + assert first_event['id'] == 'test-1' + assert 'result' in first_event + assert first_event['result']['kind'] == 'task' + assert first_event['result']['status']['state'] == 'submitted' + + # Should have status updates + status_updates = [e for e in events_received if e.get('result', {}).get('kind') == 'status-update'] + assert len(status_updates) >= 2 # At least working and completed + + # Should have artifact updates + artifact_updates = [e for e in events_received if e.get('result', {}).get('kind') == 'artifact-update'] + assert len(artifact_updates) == 4 # 4 parts + + # Last status update should be final + last_status = next( + e for e in reversed(events_received) if e.get('result', {}).get('kind') == 'status-update' + ) + assert last_status['result']['status']['state'] == 'completed' + assert last_status['result']['final'] is True + + +# ===== Artifact Streaming ===== + + +@pytest.mark.asyncio +async def test_streaming_endpoint_incremental_artifacts(streaming_app): + """Test that artifacts are streamed incrementally.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + request_data = { + 'jsonrpc': '2.0', + 'id': 'test-2', + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Test artifacts'}], + 'messageId': 'msg-2', + 'kind': 'message', + } + }, + } + + artifact_events = [] + + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + if event_data.get('result', {}).get('kind') == 'artifact-update': + artifact_events.append(event_data['result']) + + # Verify artifact streaming + assert len(artifact_events) == 4 + + # First artifact should not append + assert 'append' not in artifact_events[0] or artifact_events[0]['append'] is False + assert artifact_events[0]['artifact']['parts'][0]['text'] == 'Hello' + + # Subsequent artifacts should append + assert artifact_events[1]['append'] is True + assert artifact_events[1]['artifact']['parts'][0]['text'] == ' from' + + assert artifact_events[2]['append'] is True + assert artifact_events[2]['artifact']['parts'][0]['text'] == ' streaming' + + # Last artifact should be marked as last chunk + assert artifact_events[3]['append'] is True + assert artifact_events[3]['artifact']['parts'][0]['text'] == ' worker!' + assert artifact_events[3]['lastChunk'] is True + + +# ===== Streaming vs Non-Streaming Comparison ===== + + +@pytest.mark.asyncio +async def test_streaming_vs_non_streaming_endpoints(streaming_app): + """Test that both streaming and non-streaming endpoints work.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + # First, test non-streaming endpoint + non_streaming_request = { + 'jsonrpc': '2.0', + 'id': 'test-3', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Non-streaming test'}], + 'messageId': 'msg-3', + 'kind': 'message', + } + }, + } + + # Non-streaming should return immediately with task + response = await client.post('/', json=non_streaming_request) + assert response.status_code == 200 + data = response.json() + assert data['result']['kind'] == 'task' + assert data['result']['status']['state'] == 'submitted' + task_id = data['result']['id'] + + # Wait a bit for task to complete + await asyncio.sleep(0.5) + + # Check task status + get_task_request = {'jsonrpc': '2.0', 'id': 'test-4', 'method': 'tasks/get', 'params': {'id': task_id}} + + response = await client.post('/', json=get_task_request) + assert response.status_code == 200 + data = response.json() + assert data['result']['status']['state'] == 'completed' + assert len(data['result'].get('artifacts', [])) == 1 + + +# ===== Agent Card and Capabilities ===== + + +@pytest.mark.asyncio +async def test_agent_card_shows_streaming_capability(streaming_app): + """Test that agent card correctly reports streaming capability.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + response = await client.get('/.well-known/agent.json') + assert response.status_code == 200 + + agent_card = response.json() + assert agent_card['capabilities']['streaming'] is True + assert agent_card['capabilities']['pushNotifications'] is False + assert agent_card['capabilities']['stateTransitionHistory'] is False + + +@pytest.mark.asyncio +async def test_non_streaming_app(): + """Test app with streaming disabled.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + + app = FastA2A( + storage=storage, + broker=broker, + streaming=False, # Streaming disabled + ) + + async with LifespanManager(app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + # Check agent card + response = await client.get('/.well-known/agent.json') + assert response.status_code == 200 + + agent_card = response.json() + assert agent_card['capabilities']['streaming'] is False + + +# ===== ID Validation Tests ===== + + +@pytest.mark.asyncio +async def test_streaming_null_id_accepted(streaming_app): + """Test streaming endpoint with explicit null ID - should work.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + # Request with explicit null ID + request_data = { + 'jsonrpc': '2.0', + 'id': None, + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Test'}], + 'messageId': 'msg-null', + 'kind': 'message', + } + }, + } + + events_received = [] + + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + events_received.append(event_data) + # Stop after first event + break + + # Verify the event has null ID + assert len(events_received) > 0 + assert events_received[0]['id'] is None + + +@pytest.mark.asyncio +async def test_streaming_numeric_id_accepted(streaming_app): + """Test streaming endpoint with numeric ID - should work per JSON-RPC spec.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + # Request with numeric ID + request_data = { + 'jsonrpc': '2.0', + 'id': 12345, + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Test'}], + 'messageId': 'msg-numeric', + 'kind': 'message', + } + }, + } + + events_received = [] + + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + events_received.append(event_data) + # Stop after first event + break + + # Verify the event has numeric ID + assert len(events_received) > 0 + assert events_received[0]['id'] == 12345 + + +@pytest.mark.asyncio +async def test_streaming_large_message(streaming_app): + """Test streaming with a large message payload.""" + async with LifespanManager(streaming_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' + ) as client: + # Create a large message + large_text = 'x' * 10000 # 10KB of text + request_data = { + 'jsonrpc': '2.0', + 'id': 'test-large', + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': large_text}], + 'messageId': 'msg-large', + 'kind': 'message', + } + }, + } + + events_received = [] + + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + events_received.append(event_data) + + # Should still process successfully + assert len(events_received) > 0 + # Check we got a completed status + last_status = next( + e for e in reversed(events_received) if e.get('result', {}).get('kind') == 'status-update' + ) + assert last_status['result']['status']['state'] == 'completed' diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py new file mode 100644 index 0000000..9cc5bf5 --- /dev/null +++ b/tests/test_task_manager.py @@ -0,0 +1,438 @@ +"""Tests for TaskManager class.""" + +import pytest + +from fasta2a.broker import InMemoryBroker +from fasta2a.schema import TaskIdParams, TaskSendParams +from fasta2a.storage import InMemoryStorage +from fasta2a.task_manager import TaskManager + + +@pytest.mark.asyncio +async def test_task_manager_context_manager(): + """Test TaskManager as async context manager.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + # Not running before entering context + assert not task_manager.is_running + + async with task_manager: + # Should be running inside context + assert task_manager.is_running + + # Not running after exiting context + assert not task_manager.is_running + + +@pytest.mark.asyncio +async def test_task_manager_exit_without_enter(): + """Test exiting TaskManager without entering raises error.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + with pytest.raises(RuntimeError, match='TaskManager was not properly initialized'): + await task_manager.__aexit__(None, None, None) + + +@pytest.mark.asyncio +async def test_send_message(): + """Test send_message method.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + request = { + 'jsonrpc': '2.0', + 'id': 'req-1', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-1', + 'kind': 'message', + } + }, + } + + response = await task_manager.send_message(request) + + assert response['jsonrpc'] == '2.0' + assert response['id'] == 'req-1' + assert 'result' in response + + task = response['result'] + assert task['kind'] == 'task' + assert task['status']['state'] == 'submitted' + assert 'id' in task + assert 'contextId' in task + + +@pytest.mark.asyncio +async def test_send_message_with_context_id(): + """Test send_message with explicit context_id.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + request = { + 'jsonrpc': '2.0', + 'id': 'req-2', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-2', + 'kind': 'message', + 'context_id': 'custom-context-123', + } + }, + } + + response = await task_manager.send_message(request) + task = response['result'] + assert task['contextId'] == 'custom-context-123' + + +@pytest.mark.asyncio +async def test_send_message_with_history_length(): + """Test send_message with history_length configuration.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + # Mock broker.run_task to capture params + run_task_calls = [] + original_run_task = broker.run_task + + async def mock_run_task(params: TaskSendParams): + run_task_calls.append(params) + return await original_run_task(params) + + broker.run_task = mock_run_task + + async with broker: + async with task_manager: + request = { + 'jsonrpc': '2.0', + 'id': 'req-3', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-3', + 'kind': 'message', + }, + 'configuration': {'history_length': 10}, + }, + } + + await task_manager.send_message(request) + + # Verify history_length was passed to broker + assert len(run_task_calls) == 1 + assert run_task_calls[0]['history_length'] == 10 + + +@pytest.mark.asyncio +async def test_get_task(): + """Test get_task method.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + # First create a task + send_request = { + 'jsonrpc': '2.0', + 'id': 'req-4', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-4', + 'kind': 'message', + } + }, + } + + send_response = await task_manager.send_message(send_request) + task_id = send_response['result']['id'] + + # Now get the task + get_request = {'jsonrpc': '2.0', 'id': 'req-5', 'method': 'tasks/get', 'params': {'id': task_id}} + + get_response = await task_manager.get_task(get_request) + + assert get_response['jsonrpc'] == '2.0' + assert get_response['id'] == 'req-5' + assert 'result' in get_response + assert get_response['result']['id'] == task_id + + +@pytest.mark.asyncio +async def test_get_task_not_found(): + """Test get_task with non-existent task.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + get_request = { + 'jsonrpc': '2.0', + 'id': 'req-6', + 'method': 'tasks/get', + 'params': {'id': 'non-existent-task'}, + } + + get_response = await task_manager.get_task(get_request) + + assert get_response['jsonrpc'] == '2.0' + assert get_response['id'] == 'req-6' + assert 'error' in get_response + assert get_response['error']['code'] == -32001 + assert get_response['error']['message'] == 'Task not found' + + +@pytest.mark.asyncio +async def test_get_task_with_history_length(): + """Test get_task with history_length parameter.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + # Mock storage.load_task to capture history_length + load_task_calls = [] + original_load_task = storage.load_task + + async def mock_load_task(task_id: str, history_length: int | None = None): + load_task_calls.append((task_id, history_length)) + return await original_load_task(task_id, history_length) + + storage.load_task = mock_load_task + + async with broker: + async with task_manager: + # Create a task first + send_request = { + 'jsonrpc': '2.0', + 'id': 'req-7', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-7', + 'kind': 'message', + } + }, + } + + send_response = await task_manager.send_message(send_request) + task_id = send_response['result']['id'] + + # Get task with history_length + get_request = { + 'jsonrpc': '2.0', + 'id': 'req-8', + 'method': 'tasks/get', + 'params': {'id': task_id, 'history_length': 5}, + } + + await task_manager.get_task(get_request) + + # Verify history_length was passed + assert any(call[0] == task_id and call[1] == 5 for call in load_task_calls) + + +@pytest.mark.asyncio +async def test_cancel_task(): + """Test cancel_task method.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + # Track cancel calls + cancel_calls = [] + original_cancel = broker.cancel_task + + async def mock_cancel(params: TaskIdParams): + cancel_calls.append(params) + return await original_cancel(params) + + broker.cancel_task = mock_cancel + + async with broker: + async with task_manager: + # Create a task first + send_request = { + 'jsonrpc': '2.0', + 'id': 'req-9', + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-9', + 'kind': 'message', + } + }, + } + + send_response = await task_manager.send_message(send_request) + task_id = send_response['result']['id'] + + # Cancel the task + cancel_request = {'jsonrpc': '2.0', 'id': 'req-10', 'method': 'tasks/cancel', 'params': {'id': task_id}} + + cancel_response = await task_manager.cancel_task(cancel_request) + + assert cancel_response['jsonrpc'] == '2.0' + assert cancel_response['id'] == 'req-10' + assert 'result' in cancel_response + assert cancel_response['result']['id'] == task_id + + # Verify broker.cancel_task was called + assert len(cancel_calls) == 1 + assert cancel_calls[0]['id'] == task_id + + +@pytest.mark.asyncio +async def test_cancel_task_not_found(): + """Test cancel_task with non-existent task.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + cancel_request = { + 'jsonrpc': '2.0', + 'id': 'req-11', + 'method': 'tasks/cancel', + 'params': {'id': 'non-existent-task'}, + } + + cancel_response = await task_manager.cancel_task(cancel_request) + + assert cancel_response['jsonrpc'] == '2.0' + assert cancel_response['id'] == 'req-11' + assert 'error' in cancel_response + assert cancel_response['error']['code'] == -32001 + assert cancel_response['error']['message'] == 'Task not found' + + +@pytest.mark.asyncio +async def test_stream_message(): + """Test stream_message method.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + async with broker: + async with task_manager: + request = { + 'jsonrpc': '2.0', + 'id': 'req-12', + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello streaming'}], + 'messageId': 'msg-12', + 'kind': 'message', + } + }, + } + + events = [] + async for event in task_manager.stream_message(request): + events.append(event) + # Simulate sending a final event to end the stream + if len(events) == 1 and event['kind'] == 'task': + task_id = event['id'] + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': event['contextId'], + 'status': {'state': 'completed'}, + 'final': True, + }, + ) + + # Should have received at least the task and final status + assert len(events) >= 2 + assert events[0]['kind'] == 'task' + assert events[-1]['kind'] == 'status-update' + assert events[-1]['final'] is True + + +@pytest.mark.asyncio +async def test_stream_message_with_context_and_history(): + """Test stream_message with context_id and history_length.""" + storage = InMemoryStorage() + broker = InMemoryBroker() + task_manager = TaskManager(broker=broker, storage=storage) + + # Track run_task calls + run_task_calls = [] + + async def mock_run(params: TaskSendParams): + run_task_calls.append(params) + # Don't actually run to avoid background tasks + pass + + broker.run_task = mock_run + + async with broker: + async with task_manager: + request = { + 'jsonrpc': '2.0', + 'id': 'req-13', + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': 'Hello'}], + 'messageId': 'msg-13', + 'kind': 'message', + 'context_id': 'stream-context-123', + }, + 'configuration': {'history_length': 15}, + }, + } + + # Get first event (the task) + async for event in task_manager.stream_message(request): + if event['kind'] == 'task': + task_id = event['id'] + # End stream immediately + await broker.send_stream_event( + task_id, + { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'stream-context-123', + 'status': {'state': 'completed'}, + 'final': True, + }, + ) + + # Verify params passed to broker + assert len(run_task_calls) == 1 + assert run_task_calls[0]['context_id'] == 'stream-context-123' + assert run_task_calls[0]['history_length'] == 15 From 64b24d414be17b5a34c7bc8fec6422f677a89beb Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Thu, 24 Jul 2025 04:58:52 +0000 Subject: [PATCH 7/9] Test cleanup --- pyproject.toml | 1 + tests/test_applications.py | 27 +- tests/test_broker.py | 378 +++++--------- tests/test_streaming_integration.py | 261 ++++------ tests/test_task_manager.py | 766 +++++++++++++++------------- uv.lock | 14 + 6 files changed, 711 insertions(+), 736 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee4a489..d1018e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dev = [ "inline-snapshot", "pytest", "pytest-asyncio", + "pytest-mock", "ruff", "pyright", ] diff --git a/tests/test_applications.py b/tests/test_applications.py index 71a8eea..d4d6630 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -55,8 +55,25 @@ async def test_agent_card_with_all_params(): url='https://example.com', version='2.0.0', description='A test agent', - provider='Test Provider', - skills=['skill1', 'skill2'], + provider={'organization': 'Test Provider', 'url': 'https://example.com'}, + skills=[ + { + 'id': 'skill1', + 'name': 'Skill 1', + 'description': 'First test skill', + 'tags': ['test', 'skill1'], + 'input_modes': ['application/json'], + 'output_modes': ['application/json'], + }, + { + 'id': 'skill2', + 'name': 'Skill 2', + 'description': 'Second test skill', + 'tags': ['test', 'skill2'], + 'input_modes': ['application/json'], + 'output_modes': ['application/json'], + }, + ], streaming=True, ) async with create_test_client(app) as client: @@ -67,8 +84,10 @@ async def test_agent_card_with_all_params(): assert data['url'] == 'https://example.com' assert data['version'] == '2.0.0' assert data['description'] == 'A test agent' - assert data['provider'] == 'Test Provider' - assert data['skills'] == ['skill1', 'skill2'] + assert data['provider']['organization'] == 'Test Provider' + assert len(data['skills']) == 2 + assert data['skills'][0]['id'] == 'skill1' + assert data['skills'][1]['id'] == 'skill2' assert data['capabilities']['streaming'] is True diff --git a/tests/test_broker.py b/tests/test_broker.py index ef64704..c5f8239 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -1,12 +1,48 @@ """Tests for the broker pub/sub functionality.""" import asyncio +from typing import Any, cast import anyio import pytest from fasta2a.broker import InMemoryBroker, StreamEvent -from fasta2a.schema import Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent +from fasta2a.schema import Task, TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent + + +def make_status_event( + task_id: str, state: TaskState = 'working', final: bool = False, metadata: dict[str, Any] | None = None +) -> TaskStatusUpdateEvent: + """Create a TaskStatusUpdateEvent with common defaults.""" + event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'status': {'state': state}, + 'final': final, + } + if metadata: + event['metadata'] = metadata + return event + + +def make_artifact_event( + task_id: str, text: str, append: bool = False, last_chunk: bool = False +) -> TaskArtifactUpdateEvent: + """Create a TaskArtifactUpdateEvent with common defaults.""" + return { + 'kind': 'artifact-update', + 'task_id': task_id, + 'context_id': 'test-context', + 'artifact': {'artifact_id': 'artifact-1', 'parts': [{'kind': 'text', 'text': text}]}, + 'append': append, + 'last_chunk': last_chunk, + } + + +def is_final_status_event(event: StreamEvent) -> bool: + """Check if event is a final status update.""" + return isinstance(event, dict) and event.get('kind') == 'status-update' and bool(event.get('final')) @pytest.mark.asyncio @@ -14,24 +50,27 @@ async def test_broker_pub_sub_single_subscriber(): """Test basic pub/sub with a single subscriber.""" async with InMemoryBroker() as broker: task_id = 'test-task-123' - events_received = [] + events_received: list[StreamEvent] = [] - # Create a task to track completion + # Create events to track subscriber lifecycle + subscriber_ready = asyncio.Event() subscriber_done = asyncio.Event() async def subscriber(): + # Set ready event to signal we're about to start listening + subscriber_ready.set() async for event in broker.subscribe_to_stream(task_id): events_received.append(event) # Check for final event - if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + if is_final_status_event(event): break subscriber_done.set() # Start subscriber in background subscriber_task = asyncio.create_task(subscriber()) - # Give subscriber time to set up - await asyncio.sleep(0.1) + # Wait for subscriber to be ready + await subscriber_ready.wait() # Send some events test_task: Task = { @@ -42,22 +81,10 @@ async def subscriber(): } await broker.send_stream_event(task_id, test_task) - status_update: TaskStatusUpdateEvent = { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'status': {'state': 'working'}, - 'final': False, - } + status_update = make_status_event(task_id, 'working') await broker.send_stream_event(task_id, status_update) - final_update: TaskStatusUpdateEvent = { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'status': {'state': 'completed'}, - 'final': True, - } + final_update = make_status_event(task_id, 'completed', final=True) await broker.send_stream_event(task_id, final_update) # Wait for subscriber to complete @@ -76,24 +103,28 @@ async def test_broker_pub_sub_multiple_subscribers(): """Test pub/sub with multiple subscribers to the same task.""" async with InMemoryBroker() as broker: task_id = 'test-task-456' - events_sub1 = [] - events_sub2 = [] + events_sub1: list[StreamEvent] = [] + events_sub2: list[StreamEvent] = [] - # Create completion events + # Create ready and completion events + sub1_ready = asyncio.Event() + sub2_ready = asyncio.Event() sub1_done = asyncio.Event() sub2_done = asyncio.Event() async def subscriber1(): + sub1_ready.set() async for event in broker.subscribe_to_stream(task_id): events_sub1.append(event) - if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + if is_final_status_event(event): break sub1_done.set() async def subscriber2(): + sub2_ready.set() async for event in broker.subscribe_to_stream(task_id): events_sub2.append(event) - if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final'): + if is_final_status_event(event): break sub2_done.set() @@ -101,17 +132,12 @@ async def subscriber2(): sub1_task = asyncio.create_task(subscriber1()) sub2_task = asyncio.create_task(subscriber2()) - # Give subscribers time to set up - await asyncio.sleep(0.1) + # Wait for both subscribers to be ready + await sub1_ready.wait() + await sub2_ready.wait() # Send event - test_event: TaskStatusUpdateEvent = { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'status': {'state': 'completed'}, - 'final': True, - } + test_event = make_status_event(task_id, 'completed', final=True) await broker.send_stream_event(task_id, test_event) # Wait for both subscribers @@ -134,64 +160,52 @@ async def test_broker_no_subscribers(): task_id = 'test-task-789' # This should not raise an error even with no subscribers - test_event: TaskStatusUpdateEvent = { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'status': {'state': 'working'}, - 'final': False, - } + test_event = make_status_event(task_id, 'working') await broker.send_stream_event(task_id, test_event) - # Verify no subscribers exist - assert task_id not in broker._event_subscribers + # Test passes if no error is raised when sending with no subscribers @pytest.mark.asyncio async def test_broker_subscriber_cleanup_on_disconnect(): - """Test that disconnected subscribers are automatically cleaned up.""" + """Test that broker continues to work correctly after subscribers disconnect.""" async with InMemoryBroker() as broker: task_id = 'test-task-cleanup' + first_subscriber_events: list[StreamEvent] = [] + + # Create ready event for first subscriber + first_subscriber_ready = asyncio.Event() # Create a subscriber that exits early async def early_exit_subscriber(): + first_subscriber_ready.set() async for event in broker.subscribe_to_stream(task_id): + first_subscriber_events.append(event) # Exit after first event break # Start subscriber subscriber_task = asyncio.create_task(early_exit_subscriber()) - # Give subscriber time to set up - await asyncio.sleep(0.1) - - # Verify subscriber is registered - assert task_id in broker._event_subscribers - assert len(broker._event_subscribers[task_id]) == 1 + # Wait for subscriber to be ready + await first_subscriber_ready.wait() # Send first event - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'working'}, - 'final': False, - }, - ) + await broker.send_stream_event(task_id, make_status_event(task_id, 'working')) # Wait for subscriber to exit await subscriber_task - # Give time for cleanup to happen - await asyncio.sleep(0.1) + # Verify first subscriber received the event before disconnecting + assert len(first_subscriber_events) == 1 - # Add a second subscriber to verify cleanup happens during send - events_received = [] + # Add a second subscriber to verify system continues working + events_received: list[StreamEvent] = [] + second_subscriber_ready = asyncio.Event() complete = asyncio.Event() async def second_subscriber(): + second_subscriber_ready.set() async for event in broker.subscribe_to_stream(task_id): events_received.append(event) if isinstance(event, dict) and event.get('final'): @@ -199,27 +213,22 @@ async def second_subscriber(): complete.set() sub2_task = asyncio.create_task(second_subscriber()) - await asyncio.sleep(0.1) - - # Now send another event - the first (disconnected) subscriber should be cleaned up - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'completed'}, - 'final': True, - }, - ) + await second_subscriber_ready.wait() + + # Send another event - verifies broker works after first subscriber disconnected + await broker.send_stream_event(task_id, make_status_event(task_id, 'completed', final=True)) # Wait for second subscriber await complete.wait() await sub2_task - # Verify the second subscriber got the event + # Verify the second subscriber got only the new event (not the old one) assert len(events_received) == 1 - assert events_received[0]['status']['state'] == 'completed' + event = events_received[0] + assert isinstance(event, dict) and event.get('kind') == 'status-update' + # Type narrow to TaskStatusUpdateEvent + status_event = cast(TaskStatusUpdateEvent, event) + assert status_event['status']['state'] == 'completed' @pytest.mark.asyncio @@ -227,11 +236,13 @@ async def test_broker_artifact_streaming(): """Test streaming artifact update events.""" async with InMemoryBroker() as broker: task_id = 'test-task-artifacts' - events_received = [] + events_received: list[StreamEvent] = [] + subscriber_ready = asyncio.Event() complete = asyncio.Event() async def subscriber(): + subscriber_ready.set() async for event in broker.subscribe_to_stream(task_id): events_received.append(event) if isinstance(event, dict) and event.get('kind') == 'artifact-update' and event.get('last_chunk'): @@ -240,26 +251,13 @@ async def subscriber(): # Start subscriber subscriber_task = asyncio.create_task(subscriber()) - await asyncio.sleep(0.1) + await subscriber_ready.wait() # Send artifact updates - artifact1: TaskArtifactUpdateEvent = { - 'kind': 'artifact-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'artifact': {'artifact_id': 'artifact-1', 'parts': [{'kind': 'text', 'text': 'Hello'}]}, - 'append': False, - } + artifact1 = make_artifact_event(task_id, 'Hello') await broker.send_stream_event(task_id, artifact1) - artifact2: TaskArtifactUpdateEvent = { - 'kind': 'artifact-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'artifact': {'artifact_id': 'artifact-1', 'parts': [{'kind': 'text', 'text': ' World'}]}, - 'append': True, - 'last_chunk': True, - } + artifact2 = make_artifact_event(task_id, ' World', append=True, last_chunk=True) await broker.send_stream_event(task_id, artifact2) # Wait for completion @@ -268,10 +266,28 @@ async def subscriber(): # Verify both artifacts received assert len(events_received) == 2 - assert events_received[0]['artifact']['parts'][0]['text'] == 'Hello' - assert events_received[1]['artifact']['parts'][0]['text'] == ' World' - assert events_received[1]['append'] is True - assert events_received[1]['last_chunk'] is True + + # Check first artifact + event0 = events_received[0] + assert isinstance(event0, dict) and event0.get('kind') == 'artifact-update' + # Type narrow to TaskArtifactUpdateEvent + artifact_event0 = cast(TaskArtifactUpdateEvent, event0) + artifact_parts = artifact_event0['artifact']['parts'] + assert len(artifact_parts) > 0 + part = artifact_parts[0] + assert part['kind'] == 'text' and 'text' in part and part['text'] == 'Hello' + + # Check second artifact + event1 = events_received[1] + assert isinstance(event1, dict) and event1.get('kind') == 'artifact-update' + # Type narrow to TaskArtifactUpdateEvent + artifact_event1 = cast(TaskArtifactUpdateEvent, event1) + artifact_parts = artifact_event1['artifact']['parts'] + assert len(artifact_parts) > 0 + part = artifact_parts[0] + assert part['kind'] == 'text' and 'text' in part and part['text'] == ' World' + assert artifact_event1.get('append') is True + assert artifact_event1.get('last_chunk') is True @pytest.mark.asyncio @@ -281,7 +297,7 @@ async def test_broker_concurrent_operations(): num_tasks = 5 num_events_per_task = 10 - results = {f'task-{i}': [] for i in range(num_tasks)} + results: dict[str, list[StreamEvent]] = {f'task-{i}': [] for i in range(num_tasks)} async def subscriber(task_id: str): async for event in broker.subscribe_to_stream(task_id): @@ -291,31 +307,14 @@ async def subscriber(task_id: str): async def publisher(task_id: str): for i in range(num_events_per_task - 1): - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'working'}, - 'message_num': i, - 'final': False, - }, - ) - await asyncio.sleep(0.01) # Small delay to test ordering + event = make_status_event(task_id, 'working', metadata={'message_num': i}) + await broker.send_stream_event(task_id, event) # Send final event - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'completed'}, - 'message_num': num_events_per_task - 1, - 'final': True, - }, + final_event = make_status_event( + task_id, 'completed', final=True, metadata={'message_num': num_events_per_task - 1} ) + await broker.send_stream_event(task_id, final_event) # Start all subscribers and publishers concurrently async with anyio.create_task_group() as tg: @@ -329,39 +328,9 @@ async def publisher(task_id: str): task_id = f'task-{i}' assert len(results[task_id]) == num_events_per_task for j in range(num_events_per_task): - assert results[task_id][j]['message_num'] == j - - -@pytest.mark.asyncio -async def test_broker_closed_stream_handling(): - """Test handling of closed streams when sending events.""" - async with InMemoryBroker() as broker: - task_id = 'test-task-closed' - - # Create a stream and immediately close the receive side - send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=10) - - # Manually add the send stream to subscribers - async with broker._subscriber_lock: - broker._event_subscribers[task_id] = [send_stream] - - # Close the receive stream to simulate disconnection - await receive_stream.aclose() - - # Now try to send an event - should handle the closed stream gracefully - test_event: TaskStatusUpdateEvent = { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'test-context', - 'status': {'state': 'working'}, - 'final': False, - } - - # This should not raise an error - await broker.send_stream_event(task_id, test_event) - - # Verify the closed stream was removed - assert task_id not in broker._event_subscribers + event = results[task_id][j] + assert isinstance(event, dict) and 'metadata' in event + assert event['metadata']['message_num'] == j @pytest.mark.asyncio @@ -369,106 +338,35 @@ async def test_broker_early_final_event(): """Test that subscription stops on receiving a final event.""" async with InMemoryBroker() as broker: task_id = 'test-task-early-final' - events_received = [] + events_received: list[StreamEvent] = [] + + subscriber_ready = asyncio.Event() async def subscriber(): + subscriber_ready.set() async for event in broker.subscribe_to_stream(task_id): events_received.append(event) # Start subscriber subscriber_task = asyncio.create_task(subscriber()) - await asyncio.sleep(0.1) + await subscriber_ready.wait() # Send a non-final event - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'working'}, - 'final': False, - }, - ) + await broker.send_stream_event(task_id, make_status_event(task_id, 'working')) # Send a final event - this should cause the subscriber to exit - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'completed'}, - 'final': True, - }, - ) + await broker.send_stream_event(task_id, make_status_event(task_id, 'completed', final=True)) # Wait for subscriber to complete await subscriber_task # Send another event after subscriber has exited - no subscribers should receive this - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'ctx', - 'status': {'state': 'working'}, - 'final': False, - }, - ) + await broker.send_stream_event(task_id, make_status_event(task_id, 'working')) # Should have received only 2 events (not the third) assert len(events_received) == 2 - assert events_received[1]['final'] is True - - -@pytest.mark.asyncio -async def test_broker_subscriber_double_removal(): - """Test edge case of trying to remove a subscriber twice.""" - async with InMemoryBroker() as broker: - task_id = 'test-task-double-remove' - - # Create and manually manage a subscriber - send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=10) - - # Add subscriber - async with broker._subscriber_lock: - broker._event_subscribers[task_id] = [send_stream] - - # Remove it once - async with broker._subscriber_lock: - broker._event_subscribers[task_id].remove(send_stream) - if not broker._event_subscribers[task_id]: - del broker._event_subscribers[task_id] - - # Add it back to test the ValueError path - async with broker._subscriber_lock: - broker._event_subscribers[task_id] = [send_stream] - - # Now use subscribe_to_stream to create a proper subscription - events = [] - - async def test_subscriber(): - async for event in broker.subscribe_to_stream(task_id): - events.append(event) - # Exit after first event to trigger cleanup - break - - # Start subscriber - sub_task = asyncio.create_task(test_subscriber()) - await asyncio.sleep(0.1) - - # Manually remove the send_stream we added (not the one from subscribe_to_stream) - async with broker._subscriber_lock: - broker._event_subscribers[task_id].remove(send_stream) - - # Send event to trigger the subscriber to exit and cleanup - await broker.send_stream_event(task_id, {'kind': 'test', 'data': 'test'}) - - # Wait for subscriber - await sub_task - - # Clean up - await send_stream.aclose() - await receive_stream.aclose() + # Check the second event is final + event = events_received[1] + assert isinstance(event, dict) and event.get('kind') == 'status-update' + status_event = cast(TaskStatusUpdateEvent, event) + assert status_event['final'] is True diff --git a/tests/test_streaming_integration.py b/tests/test_streaming_integration.py index 89525f0..919e353 100644 --- a/tests/test_streaming_integration.py +++ b/tests/test_streaming_integration.py @@ -2,7 +2,7 @@ import asyncio import json -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from typing import Any @@ -18,6 +18,81 @@ from fasta2a.schema import Artifact, Message, TaskIdParams, TaskSendParams from fasta2a.storage import InMemoryStorage + +def make_stream_request( + text: str, request_id: str | int | None = 'test-req', message_id: str = 'test-msg' +) -> dict[str, Any]: + """Create a JSON-RPC streaming request with defaults.""" + return { + 'jsonrpc': '2.0', + 'id': request_id, + 'method': 'message/stream', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': text}], + 'messageId': message_id, + 'kind': 'message', + } + }, + } + + +def make_send_request( + text: str, request_id: str | int | None = 'test-req', message_id: str = 'test-msg' +) -> dict[str, Any]: + """Create a JSON-RPC send request with defaults.""" + return { + 'jsonrpc': '2.0', + 'id': request_id, + 'method': 'message/send', + 'params': { + 'message': { + 'role': 'user', + 'parts': [{'kind': 'text', 'text': text}], + 'messageId': message_id, + 'kind': 'message', + } + }, + } + + +def make_get_task_request(task_id: str, request_id: str | int | None = 'test-req') -> dict[str, Any]: + """Create a JSON-RPC get task request with defaults.""" + return {'jsonrpc': '2.0', 'id': request_id, 'method': 'tasks/get', 'params': {'id': task_id}} + + +async def collect_sse_events( + client: httpx.AsyncClient, + request_data: dict[str, Any], + stop_condition: Callable[[dict[str, Any]], bool] | None = None, +) -> list[dict[str, Any]]: + """Collect all SSE events from a streaming request.""" + events: list[dict[str, Any]] = [] + async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: + async for sse in event_source.aiter_sse(): + event_data = json.loads(sse.data) + events.append(event_data) + if stop_condition and stop_condition(event_data): + break + return events + + +def get_status_updates(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Extract status update events.""" + return [e for e in events if e.get('result', {}).get('kind') == 'status-update'] + + +def get_artifact_updates(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Extract artifact update events.""" + return [e for e in events if e.get('result', {}).get('kind') == 'artifact-update'] + + +def get_tasks(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Extract task events.""" + return [e for e in events if e.get('result', {}).get('kind') == 'task'] + + Context = list[Message] """The shape of the context you store in the storage.""" @@ -76,9 +151,6 @@ async def run_task(self, params: TaskSendParams) -> None: }, ) - # Small delay to simulate processing - await asyncio.sleep(0.05) - # Store the complete artifact complete_artifact: Artifact = { 'artifact_id': 'result-1', @@ -111,7 +183,7 @@ def build_artifacts(self, result: Any) -> list[Artifact]: @pytest_asyncio.fixture(scope='function') -async def streaming_app(): +async def streaming_app() -> FastA2A: """Create a FastA2A app with streaming enabled and a streaming worker.""" storage = InMemoryStorage() broker = InMemoryBroker() @@ -137,35 +209,15 @@ async def lifespan(app: FastA2A) -> AsyncIterator[None]: @pytest.mark.asyncio -async def test_streaming_endpoint_basic(streaming_app): +async def test_streaming_endpoint_basic(streaming_app: FastA2A) -> None: """Test basic streaming functionality.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' ) as client: # Send a streaming request - request_data = { - 'jsonrpc': '2.0', - 'id': 'test-1', - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Test streaming'}], - 'messageId': 'msg-1', - 'kind': 'message', - } - }, - } - - events_received = [] - - # Make streaming request using httpx-sse - async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: - # Collect all events - async for sse in event_source.aiter_sse(): - event_data = json.loads(sse.data) - events_received.append(event_data) + request_data = make_stream_request('Test streaming', 'test-1', 'msg-1') + events_received = await collect_sse_events(client, request_data) # Verify we received events assert len(events_received) > 0 @@ -179,17 +231,15 @@ async def test_streaming_endpoint_basic(streaming_app): assert first_event['result']['status']['state'] == 'submitted' # Should have status updates - status_updates = [e for e in events_received if e.get('result', {}).get('kind') == 'status-update'] + status_updates = get_status_updates(events_received) assert len(status_updates) >= 2 # At least working and completed # Should have artifact updates - artifact_updates = [e for e in events_received if e.get('result', {}).get('kind') == 'artifact-update'] + artifact_updates = get_artifact_updates(events_received) assert len(artifact_updates) == 4 # 4 parts # Last status update should be final - last_status = next( - e for e in reversed(events_received) if e.get('result', {}).get('kind') == 'status-update' - ) + last_status = status_updates[-1] assert last_status['result']['status']['state'] == 'completed' assert last_status['result']['final'] is True @@ -198,33 +248,15 @@ async def test_streaming_endpoint_basic(streaming_app): @pytest.mark.asyncio -async def test_streaming_endpoint_incremental_artifacts(streaming_app): +async def test_streaming_endpoint_incremental_artifacts(streaming_app: FastA2A) -> None: """Test that artifacts are streamed incrementally.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' ) as client: - request_data = { - 'jsonrpc': '2.0', - 'id': 'test-2', - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Test artifacts'}], - 'messageId': 'msg-2', - 'kind': 'message', - } - }, - } - - artifact_events = [] - - async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: - async for sse in event_source.aiter_sse(): - event_data = json.loads(sse.data) - if event_data.get('result', {}).get('kind') == 'artifact-update': - artifact_events.append(event_data['result']) + request_data = make_stream_request('Test artifacts', 'test-2', 'msg-2') + events = await collect_sse_events(client, request_data) + artifact_events = [e['result'] for e in get_artifact_updates(events)] # Verify artifact streaming assert len(artifact_events) == 4 @@ -250,26 +282,14 @@ async def test_streaming_endpoint_incremental_artifacts(streaming_app): @pytest.mark.asyncio -async def test_streaming_vs_non_streaming_endpoints(streaming_app): +async def test_streaming_vs_non_streaming_endpoints(streaming_app: FastA2A) -> None: """Test that both streaming and non-streaming endpoints work.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' ) as client: # First, test non-streaming endpoint - non_streaming_request = { - 'jsonrpc': '2.0', - 'id': 'test-3', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Non-streaming test'}], - 'messageId': 'msg-3', - 'kind': 'message', - } - }, - } + non_streaming_request = make_send_request('Non-streaming test', 'test-3', 'msg-3') # Non-streaming should return immediately with task response = await client.post('/', json=non_streaming_request) @@ -279,16 +299,20 @@ async def test_streaming_vs_non_streaming_endpoints(streaming_app): assert data['result']['status']['state'] == 'submitted' task_id = data['result']['id'] - # Wait a bit for task to complete - await asyncio.sleep(0.5) + # Check task status with minimal polling + get_task_request = make_get_task_request(task_id, 'test-4') - # Check task status - get_task_request = {'jsonrpc': '2.0', 'id': 'test-4', 'method': 'tasks/get', 'params': {'id': task_id}} + # Poll for completion with short timeout + for _ in range(10): # Try up to 10 times (1 second max) + response = await client.post('/', json=get_task_request) + assert response.status_code == 200 + data = response.json() + if data['result']['status']['state'] == 'completed': + break + await asyncio.sleep(0.1) # Small delay between polls + else: + pytest.fail(f'Task did not complete, final state: {data["result"]["status"]["state"]}') - response = await client.post('/', json=get_task_request) - assert response.status_code == 200 - data = response.json() - assert data['result']['status']['state'] == 'completed' assert len(data['result'].get('artifacts', [])) == 1 @@ -296,7 +320,7 @@ async def test_streaming_vs_non_streaming_endpoints(streaming_app): @pytest.mark.asyncio -async def test_agent_card_shows_streaming_capability(streaming_app): +async def test_agent_card_shows_streaming_capability(streaming_app: FastA2A) -> None: """Test that agent card correctly reports streaming capability.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( @@ -337,35 +361,15 @@ async def test_non_streaming_app(): @pytest.mark.asyncio -async def test_streaming_null_id_accepted(streaming_app): +async def test_streaming_null_id_accepted(streaming_app: FastA2A) -> None: """Test streaming endpoint with explicit null ID - should work.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' ) as client: # Request with explicit null ID - request_data = { - 'jsonrpc': '2.0', - 'id': None, - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Test'}], - 'messageId': 'msg-null', - 'kind': 'message', - } - }, - } - - events_received = [] - - async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: - async for sse in event_source.aiter_sse(): - event_data = json.loads(sse.data) - events_received.append(event_data) - # Stop after first event - break + request_data = make_stream_request('Test', None, 'msg-null') + events_received = await collect_sse_events(client, request_data, lambda _: True) # Stop after first event # Verify the event has null ID assert len(events_received) > 0 @@ -373,35 +377,15 @@ async def test_streaming_null_id_accepted(streaming_app): @pytest.mark.asyncio -async def test_streaming_numeric_id_accepted(streaming_app): +async def test_streaming_numeric_id_accepted(streaming_app: FastA2A) -> None: """Test streaming endpoint with numeric ID - should work per JSON-RPC spec.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=streaming_app), base_url='http://test' ) as client: # Request with numeric ID - request_data = { - 'jsonrpc': '2.0', - 'id': 12345, - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Test'}], - 'messageId': 'msg-numeric', - 'kind': 'message', - } - }, - } - - events_received = [] - - async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: - async for sse in event_source.aiter_sse(): - event_data = json.loads(sse.data) - events_received.append(event_data) - # Stop after first event - break + request_data = make_stream_request('Test', 12345, 'msg-numeric') + events_received = await collect_sse_events(client, request_data, lambda _: True) # Stop after first event # Verify the event has numeric ID assert len(events_received) > 0 @@ -409,7 +393,7 @@ async def test_streaming_numeric_id_accepted(streaming_app): @pytest.mark.asyncio -async def test_streaming_large_message(streaming_app): +async def test_streaming_large_message(streaming_app: FastA2A) -> None: """Test streaming with a large message payload.""" async with LifespanManager(streaming_app): async with httpx.AsyncClient( @@ -417,31 +401,12 @@ async def test_streaming_large_message(streaming_app): ) as client: # Create a large message large_text = 'x' * 10000 # 10KB of text - request_data = { - 'jsonrpc': '2.0', - 'id': 'test-large', - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': large_text}], - 'messageId': 'msg-large', - 'kind': 'message', - } - }, - } - - events_received = [] - - async with aconnect_sse(client, 'POST', '/', json=request_data) as event_source: - async for sse in event_source.aiter_sse(): - event_data = json.loads(sse.data) - events_received.append(event_data) + request_data = make_stream_request(large_text, 'test-large', 'msg-large') + events_received = await collect_sse_events(client, request_data) # Should still process successfully assert len(events_received) > 0 # Check we got a completed status - last_status = next( - e for e in reversed(events_received) if e.get('result', {}).get('kind') == 'status-update' - ) + status_updates = get_status_updates(events_received) + last_status = status_updates[-1] assert last_status['result']['status']['state'] == 'completed' diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py index 9cc5bf5..cebbf73 100644 --- a/tests/test_task_manager.py +++ b/tests/test_task_manager.py @@ -1,438 +1,516 @@ """Tests for TaskManager class.""" +import asyncio +from collections.abc import AsyncIterator +from typing import Any, Literal +from unittest.mock import AsyncMock + import pytest +import pytest_asyncio +from pytest_mock import MockerFixture from fasta2a.broker import InMemoryBroker -from fasta2a.schema import TaskIdParams, TaskSendParams +from fasta2a.schema import ( + CancelTaskRequest, + GetTaskRequest, + Message, + MessageSendConfiguration, + MessageSendParams, + SendMessageRequest, + StreamMessageRequest, + TaskQueryParams, + TaskSendParams, + TaskStatus, + TaskStatusUpdateEvent, + TextPart, +) from fasta2a.storage import InMemoryStorage from fasta2a.task_manager import TaskManager +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def storage() -> InMemoryStorage: + """Create an InMemoryStorage instance.""" + return InMemoryStorage() + + +@pytest.fixture +def broker() -> InMemoryBroker: + """Create an InMemoryBroker instance.""" + return InMemoryBroker() + + +@pytest.fixture +def task_manager_factory(broker: InMemoryBroker, storage: InMemoryStorage) -> TaskManager: + """Create a TaskManager instance without entering contexts.""" + return TaskManager(broker=broker, storage=storage) + + +@pytest_asyncio.fixture +async def task_manager( + broker: InMemoryBroker, storage: InMemoryStorage +) -> AsyncIterator[tuple[TaskManager, InMemoryBroker, InMemoryStorage]]: + """Create and enter TaskManager with broker contexts. + + Yields: + tuple: (task_manager, broker, storage) for tests that need access to all components + """ + tm = TaskManager(broker=broker, storage=storage) + async with broker: + async with tm: + yield tm, broker, storage + + +# ============================================================================ +# Helper Functions +# ============================================================================ + + +def send_message_request( + message: Message, req_id: str = 'req-1', configuration: MessageSendConfiguration | None = None +) -> SendMessageRequest: + """Build a SendMessageRequest.""" + params: MessageSendParams = {'message': message} + if configuration: + params['configuration'] = configuration + return { + 'jsonrpc': '2.0', + 'id': req_id, + 'method': 'message/send', + 'params': params, + } + + +def stream_message_request( + message: Message, req_id: str = 'req-1', configuration: MessageSendConfiguration | None = None +) -> StreamMessageRequest: + """Build a StreamMessageRequest.""" + params: MessageSendParams = {'message': message} + if configuration: + params['configuration'] = configuration + return { + 'jsonrpc': '2.0', + 'id': req_id, + 'method': 'message/stream', + 'params': params, + } + + +def get_task_request(task_id: str, req_id: str = 'req-1', history_length: int | None = None) -> GetTaskRequest: + """Build a GetTaskRequest.""" + params: TaskQueryParams = {'id': task_id} + if history_length is not None: + params['history_length'] = history_length + return { + 'jsonrpc': '2.0', + 'id': req_id, + 'method': 'tasks/get', + 'params': params, + } + + +def cancel_task_request(task_id: str, req_id: str = 'req-1') -> CancelTaskRequest: + """Build a CancelTaskRequest.""" + return { + 'jsonrpc': '2.0', + 'id': req_id, + 'method': 'tasks/cancel', + 'params': {'id': task_id}, + } + + +def create_test_message( + role: Literal['user', 'agent'] = 'user', + text: str = 'Hello', + message_id: str = 'msg-1', + context_id: str | None = None, +) -> Message: + """Helper to create a properly typed Message.""" + text_part: TextPart = { + 'kind': 'text', + 'text': text, + } + message: Message = { + 'role': role, # No cast needed now! + 'parts': [text_part], + 'message_id': message_id, + 'kind': 'message', + } + if context_id is not None: + message['context_id'] = context_id + return message + + +# ============================================================================ +# Tests +# ============================================================================ + @pytest.mark.asyncio -async def test_task_manager_context_manager(): +async def test_task_manager_context_manager(task_manager_factory: TaskManager): """Test TaskManager as async context manager.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) - # Not running before entering context - assert not task_manager.is_running + assert not task_manager_factory.is_running - async with task_manager: + async with task_manager_factory: # Should be running inside context - assert task_manager.is_running + assert task_manager_factory.is_running # Not running after exiting context - assert not task_manager.is_running + assert not task_manager_factory.is_running @pytest.mark.asyncio -async def test_task_manager_exit_without_enter(): +async def test_task_manager_exit_without_enter(task_manager_factory: TaskManager): """Test exiting TaskManager without entering raises error.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) - with pytest.raises(RuntimeError, match='TaskManager was not properly initialized'): - await task_manager.__aexit__(None, None, None) + await task_manager_factory.__aexit__(None, None, None) @pytest.mark.asyncio -async def test_send_message(): +async def test_send_message( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test send_message method.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, broker, _storage = task_manager - async with broker: - async with task_manager: - request = { - 'jsonrpc': '2.0', - 'id': 'req-1', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-1', - 'kind': 'message', - } - }, - } + # Mock broker.run_task + mock_run_task = mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) + + # Create and send message + message = create_test_message(text='Hello', message_id='msg-1') + request = send_message_request(message, req_id='req-1') + + response = await tm.send_message(request) - response = await task_manager.send_message(request) + # Verify response + assert response['jsonrpc'] == '2.0' + assert response['id'] == 'req-1' + assert 'result' in response - assert response['jsonrpc'] == '2.0' - assert response['id'] == 'req-1' - assert 'result' in response + task = response['result'] + assert task['kind'] == 'task' + assert task['status']['state'] == 'submitted' + assert 'id' in task + assert 'context_id' in task - task = response['result'] - assert task['kind'] == 'task' - assert task['status']['state'] == 'submitted' - assert 'id' in task - assert 'contextId' in task + # Verify broker.run_task was called + mock_run_task.assert_called_once() @pytest.mark.asyncio -async def test_send_message_with_context_id(): +async def test_send_message_with_context_id( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test send_message with explicit context_id.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, broker, _storage = task_manager - async with broker: - async with task_manager: - request = { - 'jsonrpc': '2.0', - 'id': 'req-2', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-2', - 'kind': 'message', - 'context_id': 'custom-context-123', - } - }, - } + # Mock broker.run_task + mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) - response = await task_manager.send_message(request) - task = response['result'] - assert task['contextId'] == 'custom-context-123' + # Create message with context_id + message = create_test_message(text='Hello', message_id='msg-2', context_id='custom-context-123') + request = send_message_request(message, req_id='req-2') + response = await tm.send_message(request) -@pytest.mark.asyncio -async def test_send_message_with_history_length(): - """Test send_message with history_length configuration.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + assert 'result' in response + task = response['result'] + assert 'context_id' in task + assert task['context_id'] == 'custom-context-123' - # Mock broker.run_task to capture params - run_task_calls = [] - original_run_task = broker.run_task - async def mock_run_task(params: TaskSendParams): - run_task_calls.append(params) - return await original_run_task(params) +@pytest.mark.asyncio +async def test_send_message_with_history_length( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: + """Test send_message with history_length configuration.""" + tm, broker, _storage = task_manager - broker.run_task = mock_run_task + # Mock broker.run_task + mock_run_task = mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) - async with broker: - async with task_manager: - request = { - 'jsonrpc': '2.0', - 'id': 'req-3', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-3', - 'kind': 'message', - }, - 'configuration': {'history_length': 10}, - }, - } + # Create message with configuration + message = create_test_message(text='Hello', message_id='msg-3') + configuration: MessageSendConfiguration = { + 'history_length': 10, + 'accepted_output_modes': ['text/plain'], + } + request = send_message_request(message, req_id='req-3', configuration=configuration) - await task_manager.send_message(request) + await tm.send_message(request) - # Verify history_length was passed to broker - assert len(run_task_calls) == 1 - assert run_task_calls[0]['history_length'] == 10 + # Verify history_length was passed to broker + mock_run_task.assert_called_once() + call_args = mock_run_task.call_args[0][0] + assert 'history_length' in call_args + assert call_args['history_length'] == 10 @pytest.mark.asyncio -async def test_get_task(): +async def test_get_task( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test get_task method.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, broker, _storage = task_manager - async with broker: - async with task_manager: - # First create a task - send_request = { - 'jsonrpc': '2.0', - 'id': 'req-4', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-4', - 'kind': 'message', - } - }, - } + # Mock broker.run_task + mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) - send_response = await task_manager.send_message(send_request) - task_id = send_response['result']['id'] + # First create a task + message = create_test_message(text='Hello', message_id='msg-4') + send_request = send_message_request(message, req_id='req-4') - # Now get the task - get_request = {'jsonrpc': '2.0', 'id': 'req-5', 'method': 'tasks/get', 'params': {'id': task_id}} + send_response = await tm.send_message(send_request) + assert 'result' in send_response + assert 'id' in send_response['result'] + task_id = send_response['result']['id'] - get_response = await task_manager.get_task(get_request) + # Now get the task + get_request = get_task_request(task_id, req_id='req-5') + get_response = await tm.get_task(get_request) - assert get_response['jsonrpc'] == '2.0' - assert get_response['id'] == 'req-5' - assert 'result' in get_response - assert get_response['result']['id'] == task_id + assert get_response['jsonrpc'] == '2.0' + assert get_response['id'] == 'req-5' + assert 'result' in get_response + assert get_response['result']['id'] == task_id @pytest.mark.asyncio -async def test_get_task_not_found(): +async def test_get_task_not_found(task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage]) -> None: """Test get_task with non-existent task.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, _broker, _storage = task_manager - async with broker: - async with task_manager: - get_request = { - 'jsonrpc': '2.0', - 'id': 'req-6', - 'method': 'tasks/get', - 'params': {'id': 'non-existent-task'}, - } + get_request = get_task_request('non-existent-task', req_id='req-6') + get_response = await tm.get_task(get_request) - get_response = await task_manager.get_task(get_request) - - assert get_response['jsonrpc'] == '2.0' - assert get_response['id'] == 'req-6' - assert 'error' in get_response - assert get_response['error']['code'] == -32001 - assert get_response['error']['message'] == 'Task not found' + assert get_response['jsonrpc'] == '2.0' + assert get_response['id'] == 'req-6' + assert 'error' in get_response + assert get_response['error']['code'] == -32001 + assert get_response['error']['message'] == 'Task not found' @pytest.mark.asyncio -async def test_get_task_with_history_length(): +async def test_get_task_with_history_length( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test get_task with history_length parameter.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, broker, storage = task_manager - # Mock storage.load_task to capture history_length - load_task_calls = [] - original_load_task = storage.load_task + # Mock broker.run_task + mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) - async def mock_load_task(task_id: str, history_length: int | None = None): - load_task_calls.append((task_id, history_length)) - return await original_load_task(task_id, history_length) - - storage.load_task = mock_load_task - - async with broker: - async with task_manager: - # Create a task first - send_request = { - 'jsonrpc': '2.0', - 'id': 'req-7', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-7', - 'kind': 'message', - } - }, - } + # Mock storage.load_task to track calls + original_load_task = storage.load_task + mock_load_task = mocker.patch.object(storage, 'load_task', new_callable=AsyncMock) + # Make it return a valid task by calling the original + mock_load_task.side_effect = original_load_task - send_response = await task_manager.send_message(send_request) - task_id = send_response['result']['id'] + # Create a task first + message = create_test_message(text='Hello', message_id='msg-7') + send_request = send_message_request(message, req_id='req-7') - # Get task with history_length - get_request = { - 'jsonrpc': '2.0', - 'id': 'req-8', - 'method': 'tasks/get', - 'params': {'id': task_id, 'history_length': 5}, - } + send_response = await tm.send_message(send_request) + assert 'result' in send_response + assert 'id' in send_response['result'] + task_id = send_response['result']['id'] - await task_manager.get_task(get_request) + # Get task with history_length + get_request = get_task_request(task_id, req_id='req-8', history_length=5) + await tm.get_task(get_request) - # Verify history_length was passed - assert any(call[0] == task_id and call[1] == 5 for call in load_task_calls) + # Verify history_length was passed + mock_load_task.assert_called_with(task_id, 5) @pytest.mark.asyncio -async def test_cancel_task(): +async def test_cancel_task( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test cancel_task method.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) - - # Track cancel calls - cancel_calls = [] - original_cancel = broker.cancel_task + tm, broker, _storage = task_manager - async def mock_cancel(params: TaskIdParams): - cancel_calls.append(params) - return await original_cancel(params) + # Mock broker methods + mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) + mock_cancel_task = mocker.patch.object(broker, 'cancel_task', new_callable=AsyncMock) - broker.cancel_task = mock_cancel - - async with broker: - async with task_manager: - # Create a task first - send_request = { - 'jsonrpc': '2.0', - 'id': 'req-9', - 'method': 'message/send', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-9', - 'kind': 'message', - } - }, - } + # Create a task first + message = create_test_message(text='Hello', message_id='msg-9') + send_request = send_message_request(message, req_id='req-9') - send_response = await task_manager.send_message(send_request) - task_id = send_response['result']['id'] + send_response = await tm.send_message(send_request) + assert 'result' in send_response + assert 'id' in send_response['result'] + task_id = send_response['result']['id'] - # Cancel the task - cancel_request = {'jsonrpc': '2.0', 'id': 'req-10', 'method': 'tasks/cancel', 'params': {'id': task_id}} + # Cancel the task + cancel_request = cancel_task_request(task_id, req_id='req-10') + cancel_response = await tm.cancel_task(cancel_request) - cancel_response = await task_manager.cancel_task(cancel_request) + assert cancel_response['jsonrpc'] == '2.0' + assert cancel_response['id'] == 'req-10' + assert 'result' in cancel_response + assert cancel_response['result']['id'] == task_id - assert cancel_response['jsonrpc'] == '2.0' - assert cancel_response['id'] == 'req-10' - assert 'result' in cancel_response - assert cancel_response['result']['id'] == task_id - - # Verify broker.cancel_task was called - assert len(cancel_calls) == 1 - assert cancel_calls[0]['id'] == task_id + # Verify broker.cancel_task was called + mock_cancel_task.assert_called_once() + assert mock_cancel_task.call_args[0][0]['id'] == task_id @pytest.mark.asyncio -async def test_cancel_task_not_found(): +async def test_cancel_task_not_found( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test cancel_task with non-existent task.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) + tm, broker, _storage = task_manager - async with broker: - async with task_manager: - cancel_request = { - 'jsonrpc': '2.0', - 'id': 'req-11', - 'method': 'tasks/cancel', - 'params': {'id': 'non-existent-task'}, - } + # Mock broker.cancel_task + mocker.patch.object(broker, 'cancel_task', new_callable=AsyncMock) - cancel_response = await task_manager.cancel_task(cancel_request) + cancel_request = cancel_task_request('non-existent-task', req_id='req-11') + cancel_response = await tm.cancel_task(cancel_request) - assert cancel_response['jsonrpc'] == '2.0' - assert cancel_response['id'] == 'req-11' - assert 'error' in cancel_response - assert cancel_response['error']['code'] == -32001 - assert cancel_response['error']['message'] == 'Task not found' + assert cancel_response['jsonrpc'] == '2.0' + assert cancel_response['id'] == 'req-11' + assert 'error' in cancel_response + assert cancel_response['error']['code'] == -32001 + assert cancel_response['error']['message'] == 'Task not found' @pytest.mark.asyncio -async def test_stream_message(): +async def test_stream_message( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test stream_message method.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) - - async with broker: - async with task_manager: - request = { - 'jsonrpc': '2.0', - 'id': 'req-12', - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello streaming'}], - 'messageId': 'msg-12', - 'kind': 'message', - } - }, - } - - events = [] - async for event in task_manager.stream_message(request): - events.append(event) - # Simulate sending a final event to end the stream - if len(events) == 1 and event['kind'] == 'task': - task_id = event['id'] - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': event['contextId'], - 'status': {'state': 'completed'}, - 'final': True, - }, - ) - - # Should have received at least the task and final status - assert len(events) >= 2 - assert events[0]['kind'] == 'task' - assert events[-1]['kind'] == 'status-update' - assert events[-1]['final'] is True + tm, broker, _storage = task_manager + + # Track the task info for our simulated worker + task_info: dict[str, str | None] = {'task_id': None, 'context_id': None} + + # Create side effect for simulating worker + async def simulate_worker(params: TaskSendParams): + # Capture task info + task_info['task_id'] = params['id'] + task_info['context_id'] = params['context_id'] + + # Simulate worker behavior in background + async def worker_task(): + # Small delay to ensure subscriber is ready + await asyncio.sleep(0.1) + + # Send working status + if task_info['task_id'] is not None and task_info['context_id'] is not None: + status: TaskStatus = {'state': 'working'} + event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_info['task_id'], + 'context_id': task_info['context_id'], + 'status': status, + 'final': False, + } + await broker.send_stream_event(task_info['task_id'], event) + + # Small delay for processing + await asyncio.sleep(0.1) + + # Send completion status + if task_info['task_id'] is not None and task_info['context_id'] is not None: + status: TaskStatus = {'state': 'completed'} + event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': task_info['task_id'], + 'context_id': task_info['context_id'], + 'status': status, + 'final': True, + } + await broker.send_stream_event(task_info['task_id'], event) + + # Start worker simulation in background + asyncio.create_task(worker_task()) + + # Mock broker.run_task with side effect + mocker.patch.object(broker, 'run_task', new_callable=AsyncMock, side_effect=simulate_worker) + + # Create stream request + message = create_test_message(text='Hello streaming', message_id='msg-12') + request = stream_message_request(message, req_id='req-12') + + # Collect events + events: list[Any] = [] + async for event in tm.stream_message(request): + events.append(event) + + # Should have received the task and status updates + assert len(events) == 3 # task, working status, completed status + assert events[0]['kind'] == 'task' + assert events[1]['kind'] == 'status-update' + assert events[1]['status']['state'] == 'working' + assert events[2]['kind'] == 'status-update' + assert events[2]['status']['state'] == 'completed' + assert events[2]['final'] is True @pytest.mark.asyncio -async def test_stream_message_with_context_and_history(): +async def test_stream_message_with_context_and_history( + task_manager: tuple[TaskManager, InMemoryBroker, InMemoryStorage], mocker: MockerFixture +) -> None: """Test stream_message with context_id and history_length.""" - storage = InMemoryStorage() - broker = InMemoryBroker() - task_manager = TaskManager(broker=broker, storage=storage) - - # Track run_task calls - run_task_calls = [] - - async def mock_run(params: TaskSendParams): - run_task_calls.append(params) - # Don't actually run to avoid background tasks - pass - - broker.run_task = mock_run - - async with broker: - async with task_manager: - request = { - 'jsonrpc': '2.0', - 'id': 'req-13', - 'method': 'message/stream', - 'params': { - 'message': { - 'role': 'user', - 'parts': [{'kind': 'text', 'text': 'Hello'}], - 'messageId': 'msg-13', - 'kind': 'message', - 'context_id': 'stream-context-123', - }, - 'configuration': {'history_length': 15}, - }, + tm, broker, _storage = task_manager + + # Create side effect for quick completion + async def simulate_quick_completion(params: TaskSendParams): + # Simulate worker behavior in background + async def worker_task(): + # Small delay to ensure subscriber is ready + await asyncio.sleep(0.1) + + # Send completion status immediately for this test + status: TaskStatus = {'state': 'completed'} + event: TaskStatusUpdateEvent = { + 'kind': 'status-update', + 'task_id': params['id'], + 'context_id': params['context_id'], + 'status': status, + 'final': True, } - - # Get first event (the task) - async for event in task_manager.stream_message(request): - if event['kind'] == 'task': - task_id = event['id'] - # End stream immediately - await broker.send_stream_event( - task_id, - { - 'kind': 'status-update', - 'task_id': task_id, - 'context_id': 'stream-context-123', - 'status': {'state': 'completed'}, - 'final': True, - }, - ) - - # Verify params passed to broker - assert len(run_task_calls) == 1 - assert run_task_calls[0]['context_id'] == 'stream-context-123' - assert run_task_calls[0]['history_length'] == 15 + await broker.send_stream_event(params['id'], event) + + # Start worker simulation in background + asyncio.create_task(worker_task()) + + # Mock broker.run_task with side effect + mock_run_task = mocker.patch.object( + broker, 'run_task', new_callable=AsyncMock, side_effect=simulate_quick_completion + ) + + # Create message with context and configuration + message = create_test_message(text='Hello', message_id='msg-13', context_id='stream-context-123') + configuration: MessageSendConfiguration = { + 'history_length': 15, + 'accepted_output_modes': ['text/plain'], + } + request = stream_message_request(message, req_id='req-13', configuration=configuration) + + # Collect all events + events: list[Any] = [] + async for event in tm.stream_message(request): + events.append(event) + + # Verify we got the task and completion status + assert len(events) == 2 + assert events[0]['kind'] == 'task' + assert events[0]['context_id'] == 'stream-context-123' + assert events[1]['kind'] == 'status-update' + assert events[1]['final'] is True + + # Verify params passed to broker + mock_run_task.assert_called_once() + call_args = mock_run_task.call_args[0][0] + assert call_args['context_id'] == 'stream-context-123' + assert 'history_length' in call_args + assert call_args['history_length'] == 15 diff --git a/uv.lock b/uv.lock index 4abacf7..4e88f72 100644 --- a/uv.lock +++ b/uv.lock @@ -467,6 +467,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-mock" }, { name = "ruff" }, ] docs = [ @@ -497,6 +498,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-mock" }, { name = "ruff" }, ] docs = [ @@ -1394,6 +1396,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 79fdddf5032443e91b78df2129c36948b342a9e5 Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Fri, 25 Jul 2025 16:50:22 +0000 Subject: [PATCH 8/9] Fix python 3.9 types --- tests/test_broker.py | 2 ++ tests/test_streaming_integration.py | 2 ++ tests/test_task_manager.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/test_broker.py b/tests/test_broker.py index c5f8239..abe63c7 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -1,5 +1,7 @@ """Tests for the broker pub/sub functionality.""" +from __future__ import annotations + import asyncio from typing import Any, cast diff --git a/tests/test_streaming_integration.py b/tests/test_streaming_integration.py index 919e353..52d38f4 100644 --- a/tests/test_streaming_integration.py +++ b/tests/test_streaming_integration.py @@ -1,5 +1,7 @@ """Integration tests for the streaming endpoint.""" +from __future__ import annotations + import asyncio import json from collections.abc import AsyncIterator, Callable diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py index cebbf73..f571956 100644 --- a/tests/test_task_manager.py +++ b/tests/test_task_manager.py @@ -1,5 +1,7 @@ """Tests for TaskManager class.""" +from __future__ import annotations + import asyncio from collections.abc import AsyncIterator from typing import Any, Literal From c4c16d6e33c02a2f76617d1f477437b4e6791b03 Mon Sep 17 00:00:00 2001 From: Robert Porter Date: Tue, 29 Jul 2025 20:12:48 +0000 Subject: [PATCH 9/9] Update send_messsage/stream_message to pass metadata on to broker --- .coverage | Bin 0 -> 90112 bytes ...fbcc6330-6bd87969d4-wl7xh.1933182.XnWOPWYx | Bin 0 -> 77824 bytes ...fbcc6330-6bd87969d4-wl7xh.1935624.XKqLCknx | Bin 0 -> 77824 bytes ...fbcc6330-6bd87969d4-wl7xh.1937271.XsCrUXhx | Bin 0 -> 81920 bytes STREAMING_SUMMARY.md | 90 +++++++ a2a.md | 231 ++++++++++++++++++ fasta2a/task_manager.py | 8 + tests/test_task_manager.py | 35 ++- 8 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 .coverage create mode 100644 .coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1933182.XnWOPWYx create mode 100644 .coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1935624.XKqLCknx create mode 100644 .coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1937271.XsCrUXhx create mode 100644 STREAMING_SUMMARY.md create mode 100644 a2a.md diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..41ac1f46bcc6777bb281edddb4ff671b898b93ba GIT binary patch literal 90112 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 literal 0 HcmV?d00001 diff --git a/.coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1935624.XKqLCknx b/.coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1935624.XKqLCknx new file mode 100644 index 0000000000000000000000000000000000000000..7ba98c1e444527f3953ca928ad3bcbf55f4ee169 GIT binary patch literal 77824 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 literal 0 HcmV?d00001 diff --git a/.coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1937271.XsCrUXhx b/.coverage.coder-rob-rob-web-97d3fbcc6330-6bd87969d4-wl7xh.1937271.XsCrUXhx new file mode 100644 index 0000000000000000000000000000000000000000..c614c01feaebeb900adb6439a27faf11f9d9a287 GIT binary patch literal 81920 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 literal 0 HcmV?d00001 diff --git a/STREAMING_SUMMARY.md b/STREAMING_SUMMARY.md new file mode 100644 index 0000000..21018dc --- /dev/null +++ b/STREAMING_SUMMARY.md @@ -0,0 +1,90 @@ +# SSE Streaming Implementation Summary + +## Overview +Added real-time streaming support to FastA2A via Server-Sent Events (SSE), enabling agents to stream responses as they are generated, making the framework fully compliant with the A2A specification v0.2.5. + +## Key Changes + +### 1. Broker Layer (fasta2a/broker.py) +- Added abstract methods `send_stream_event()` and `subscribe_to_stream()` to the Broker interface +- Implemented pub/sub infrastructure in InMemoryBroker: + - Thread-safe subscriber management with locks + - Automatic cleanup of disconnected subscribers + - Support for streaming Task, Message, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent + +### 2. Application Layer (fasta2a/applications.py) +- Added configurable `streaming` parameter to FastA2A constructor +- Implemented `message/stream` endpoint that returns EventSourceResponse +- Updated agent capabilities to correctly report streaming support + +### 3. Task Manager (fasta2a/task_manager.py) +- Added `stream_message()` async generator method +- Yields initial task, then streams all subsequent events from broker +- Handles task execution in background while streaming events + +### 4. Worker Integration +- Workers can emit streaming events using `broker.send_stream_event()` +- No changes to Worker base class needed - direct broker usage is cleaner + +### 5. Dependencies +- Added `sse-starlette>=2.0.0` for SSE response handling +- Added `httpx-sse` to dev dependencies for testing + +## Testing +- Comprehensive unit tests for broker pub/sub (7 tests) +- Integration tests for streaming endpoint (8 tests) +- Unit tests for task manager streaming (12 tests) +- Unit tests for agent card functionality (5 tests) +- Total: 32 tests, 90.18% coverage +- Tests use proper async synchronization instead of sleep-based timing + +## Usage Example + +```python +# Enable streaming in FastA2A +app = FastA2A( + storage=storage, + broker=broker, + streaming=True # Enable SSE streaming +) + +# Workers emit events during execution +async def run_task(self, params: TaskSendParams): + task_id = params['id'] + + # Emit status updates + await self.broker.send_stream_event(task_id, { + 'kind': 'status-update', + 'task_id': task_id, + 'status': {'state': 'working'}, + 'final': False + }) + + # Emit artifact chunks + await self.broker.send_stream_event(task_id, { + 'kind': 'artifact-update', + 'task_id': task_id, + 'artifact': {'parts': [{'kind': 'text', 'text': 'Chunk 1'}]}, + 'append': False + }) +``` + +## Client Usage + +```python +# Using httpx-sse client +async with aconnect_sse(client, 'POST', '/', json={ + 'jsonrpc': '2.0', + 'method': 'message/stream', + 'params': {'message': {...}} +}) as event_source: + async for sse in event_source.aiter_sse(): + event = json.loads(sse.data) + # Process streaming event +``` + +## Benefits +- Real-time response streaming reduces perceived latency +- Supports incremental artifact updates +- Fully compliant with A2A specification +- Backwards compatible - streaming is optional \ No newline at end of file diff --git a/a2a.md b/a2a.md new file mode 100644 index 0000000..85d9755 --- /dev/null +++ b/a2a.md @@ -0,0 +1,231 @@ +──────────────────────────────────────────────────────── +Agent‑to‑Agent Protocol (A2A) — Comprehensive Reference +Spec version : 0.2.3 • Publish date : 2025‑06‑14 +Homepage : https://a2aproject.github.io/A2A/latest/ +──────────────────────────────────────────────────────── + +1. PURPOSE & SCOPE +──────────────────────────────────────────────────────── +A2A defines **how independent AI agents communicate as peers**, with +built‑in discovery, capability negotiation, long‑running task management, +streaming, push notifications and enterprise‑grade security. Its goals are: + +• Interoperability between heterogeneous agent stacks +• Dynamic discovery of skills & auth requirements +• Secure, opaque collaboration (no internal state leaks) +• First‑class support for async / streaming & human‑in‑loop workflows +• Basing everything on well‑known Web standards (HTTP, JSON‑RPC 2.0, SSE) :contentReference[oaicite:0]{index=0} + +2. CORE MODEL +──────────────────────────────────────────────────────── +Concept | Description +-------------------- | ----------------------------------------------------- +A2A Client | Initiator acting for a user / upstream agent +A2A Server | Remote agent exposing an A2A endpoint +Task | Long‑lived stateful unit of work +Message | One conversational turn; contains **Part[]** +Artifact | Durable output produced by a task +Agent Card | JSON metadata document describing identity, skills, + | endpoint URL & security requirements :contentReference[oaicite:1]{index=1} + +3. TRANSPORT & ENCODING +──────────────────────────────────────────────────────── +Transport : **HTTPS** (TLS 1.2+) POST to Agent Card .url +Payload format : **JSON‑RPC 2.0** (`Content‑Type: application/json`) +Streaming channel : **Server‑Sent Events** (`text/event-stream`) used by + `message/stream` and `tasks/resubscribe`. +SSE payload : each event’s *data* field is a full JSON‑RPC Response. :contentReference[oaicite:2]{index=2} + +4. SECURITY MODEL +──────────────────────────────────────────────────────── +Layered security = transport TLS + declared auth schemes. + +• Auth schemes are declared in `AgentCard.securitySchemes` + – mirrors OpenAPI: `apiKey`, `http` (Basic / Bearer), `oauth2`, + `openIdConnect`. +• Required scheme combinations are listed in `AgentCard.security` +• Missing / invalid credentials → HTTP 401/403 with `WWW‑Authenticate`. +• Optional **secondary (in‑task) auth** — task transitions to + `auth-required` until satisfied. :contentReference[oaicite:3]{index=3} + +5. DISCOVERY – THE AGENT CARD +──────────────────────────────────────────────────────── +Recommended URL : `https://{host}/.well‑known/agent.json` +Minimal top‑level schema (TS‑style): + +```ts +interface AgentCard { + name: string; + description: string; + url: string; // base A2A endpoint + provider?: AgentProvider; // org info + version: string; // agent impl version + iconUrl?: string; // 64×64+ PNG/SVG + documentationUrl?: string; + capabilities: AgentCapabilities; + securitySchemes?: { [name: string]: SecurityScheme }; + security?: { [name: string]: string[] }[]; + defaultInputModes: string[]; // e.g. ["text/plain","application/json"] + defaultOutputModes: string[]; // e.g. ["application/json"] + skills: AgentSkill[]; + supportsAuthenticatedExtendedCard?: boolean; // if true: expose +} +``` :contentReference[oaicite:4]{index=4} + +Key sub‑objects +• **AgentCapabilities** → `{ streaming?: bool, pushNotifications?: bool, + stateTransitionHistory?: bool, extensions?: AgentExtension[] }` +• **AgentSkill** → `{ id, name, description, tags[], examples?[], + inputModes?, outputModes? }` +• **SecurityScheme** → union of OpenAPI schemes (API Key, HTTP, OAuth2, + OpenID Connect). :contentReference[oaicite:5]{index=5} + +6. DATA OBJECTS +──────────────────────────────────────────────────────── +6.1 Task +```ts +interface Task { + id: string; + contextId: string; + status: TaskStatus; + artifacts?: Artifact[]; + history?: Message[]; + metadata?: Record; + kind: "task"; +} +``` :contentReference[oaicite:6]{index=6} + +6.2 TaskStatus & TaskState enum +States: `submitted | working | input‑required | completed | canceled | +failed | rejected | auth‑required | unknown`. +`completed, canceled, failed, rejected` are **terminal**. :contentReference[oaicite:7]{index=7} + +6.3 Message & Part +```ts +type Part = TextPart | FilePart | DataPart; + +interface Message { + role: "user" | "agent"; + parts: Part[]; + messageId: string; + contextId?: string; + taskId?: string; + referenceTaskIds?: string[]; + metadata?: Record; + extensions?: string[]; + kind: "message"; +} +``` :contentReference[oaicite:8]{index=8} + +• **TextPart**→ `{ kind:"text", text }` +• **FilePart**→ `{ kind:"file", file: FileWithBytes | FileWithUri }` +• **DataPart**→ `{ kind:"data", data: {...} }` +• **Artifact**→ `{ artifactId, parts:Part[], name?, description?, metadata?, extensions? }` :contentReference[oaicite:9]{index=9} + +6.4 PushNotificationConfig +```ts +interface PushNotificationConfig { + id?: string; // server‑assigned + url: string; // HTTPS webhook + token?: string; // opaque HMAC/shared secret + authentication?: PushNotificationAuthenticationInfo; +} +``` :contentReference[oaicite:10]{index=10} + +6.5 JSON‑RPC Structures +`JSONRPCRequest`, `JSONRPCResponse`, `JSONRPCError` – standard 2.0 +shapes, plus A2A‑specific `result` payloads. :contentReference[oaicite:11]{index=11} + +7. RPC METHOD CATALOG +──────────────────────────────────────────────────────── +All calls are **POST** `AgentCard.url` with a JSON‑RPC 2.0 request. + +Method | Params → Result (success) | Notes +---------------------------- | --------------------------------------------- | ------------------------------------------------------ +`message/send` | **MessageSendParams** → `Task | Message` | Sync request; client polls `tasks/get` or SSE +`message/stream` | MessageSendParams → *SSE* stream of `Message` \| `Task` \| `TaskStatusUpdateEvent` \| `TaskArtifactUpdateEvent` | Requires `capabilities.streaming=true` +`tasks/get` | **/* TaskQueryParams** → `Task` | Poll task status / history +`tasks/cancel` | **TaskIdParams** → `Task` | Attempt cancellation +`tasks/pushNotificationConfig/set` | **TaskPushNotificationConfig** → same | Requires `capabilities.pushNotifications=true` +`tasks/pushNotificationConfig/get` | GetTaskPushNotificationConfigParams → TaskPushNotificationConfig | +`tasks/pushNotificationConfig/list`| ListTaskPushNotificationConfigParams → TaskPushNotificationConfig[] | +`tasks/pushNotificationConfig/delete`| TaskIdParams + configId → (void) | +`tasks/resubscribe` | TaskIdParams → *SSE* | Re‑attach after network drop +`agent/authenticatedExtendedCard` | AuthenticatedExtendedCardParams → AgentCard | Returns richer card after auth :contentReference[oaicite:12]{index=12} + +Essential parameter types +```ts +interface MessageSendParams { + message: Message; + configuration?: { + acceptedOutputModes: string[]; + historyLength?: number; + pushNotificationConfig?: PushNotificationConfig; + blocking?: boolean; // if true: HTTP held open until terminal + }; + metadata?: Record; +} + +interface TaskQueryParams { id: string; historyLength?: number } +interface TaskIdParams { id: string } +interface TaskPushNotificationConfig { taskId: string; pushNotificationConfig: PushNotificationConfig } +``` :contentReference[oaicite:13]{index=13} + +7.1 Streaming events +• **TaskStatusUpdateEvent** → `{ taskId, contextId, kind:"status‑update", status:TaskStatus, final:boolean }` +• **TaskArtifactUpdateEvent** → `{ taskId, contextId, kind:"artifact-update", artifact:Artifact, append?, lastChunk? }` :contentReference[oaicite:14]{index=14} + +8. ERROR HANDLING +──────────────────────────────────────────────────────── +A2A reuses JSON‑RPC codes and reserves **‑32000…‑32099** for protocol‑specific +errors. + +Code | Name | Typical meaning +------ | --------------------------------- | ---------------------------------------- +‑32001 | TaskNotFoundError | Unknown/expired task ID +‑32002 | TaskNotCancelableError | Task is not in cancelable state +‑32003 | PushNotificationNotSupportedError | Server lacks push‑notification capability +‑32004 | UnsupportedOperationError | Feature or parameter not supported +‑32005 | ContentTypeNotSupportedError | Unaccepted MIME type in parts/artifacts +‑32006 | InvalidAgentResponseError | Server produced invalid response shape :contentReference[oaicite:15]{index=15} +Standard codes ‑32700…‑32603 follow JSON‑RPC 2.0. Include explanatory +`message` and optional structured `data`. + +9. TYPICAL WORKFLOWS (NON‑NORMATIVE) +──────────────────────────────────────────────────────── +**Synchronous / Polling** + +1. Fetch Agent Card → pick skill → form first `Message` +2. `message/send` → server returns `Task { state:working }` +3. Client polls `tasks/get` until terminal state +4. On `completed` → download artifacts/messages as needed + +**Streaming** + +1. `message/stream` → HTTP 200 + SSE +2. Parse stream events; optionally `tasks/resubscribe` if dropped +3. Close stream when final status update or artifact chunk with `lastChunk:true` + +**Input‑Required turn** + +*Task* enters `input‑required` → client collects user input → call +`message/send` with same `taskId` (inside Message.taskId). Task continues. + +**Push‑notifications** + +• Register via `tasks/pushNotificationConfig/set` → Server POSTs JSON‑RPC +envelopes to `url`, signed per `token` and `authentication`. :contentReference[oaicite:16]{index=16} + +10. EXTENSIBILITY +──────────────────────────────────────────────────────── +• **Extensions** declared in `AgentCapabilities.extensions` and echoed in +messages/artifacts via the `extensions` string array. +• New RPC methods MAY be added using `namespace/verb` naming, provided +clients fail gracefully on `‑32601 Method not found`. +• New TaskState values SHOULD reserve lower‑case, dash‑separated strings and +be treated as non‑terminal unless explicitly documented. + +──────────────────────────────────────────────────────── +END OF SPEC (A2A v0.2.3) +──────────────────────────────────────────────────────── */ + diff --git a/fasta2a/task_manager.py b/fasta2a/task_manager.py index 931e597..8aa156f 100644 --- a/fasta2a/task_manager.py +++ b/fasta2a/task_manager.py @@ -127,6 +127,10 @@ async def send_message(self, request: SendMessageRequest) -> SendMessageResponse if history_length is not None: broker_params['history_length'] = history_length + metadata = request['params'].get('metadata') + if metadata is not None: + broker_params['metadata'] = metadata + await self.broker.run_task(broker_params) return SendMessageResponse(jsonrpc='2.0', id=request_id, result=task) @@ -185,6 +189,10 @@ async def stream_message(self, request: StreamMessageRequest) -> AsyncGenerator[ if history_length is not None: broker_params['history_length'] = history_length + metadata = params.get('metadata') + if metadata is not None: + broker_params['metadata'] = metadata + # Start task execution in background asyncio.create_task(self.broker.run_task(broker_params)) diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py index f571956..2ad78d1 100644 --- a/tests/test_task_manager.py +++ b/tests/test_task_manager.py @@ -73,12 +73,17 @@ async def task_manager( def send_message_request( - message: Message, req_id: str = 'req-1', configuration: MessageSendConfiguration | None = None + message: Message, + req_id: str = 'req-1', + configuration: MessageSendConfiguration | None = None, + metadata: dict[str, Any] | None = None, ) -> SendMessageRequest: """Build a SendMessageRequest.""" params: MessageSendParams = {'message': message} if configuration: params['configuration'] = configuration + if metadata: + params['metadata'] = metadata return { 'jsonrpc': '2.0', 'id': req_id, @@ -88,12 +93,17 @@ def send_message_request( def stream_message_request( - message: Message, req_id: str = 'req-1', configuration: MessageSendConfiguration | None = None + message: Message, + req_id: str = 'req-1', + configuration: MessageSendConfiguration | None = None, + metadata: dict[str, Any] | None = None, ) -> StreamMessageRequest: """Build a StreamMessageRequest.""" params: MessageSendParams = {'message': message} if configuration: params['configuration'] = configuration + if metadata: + params['metadata'] = metadata return { 'jsonrpc': '2.0', 'id': req_id, @@ -183,9 +193,10 @@ async def test_send_message( # Mock broker.run_task mock_run_task = mocker.patch.object(broker, 'run_task', new_callable=AsyncMock) - # Create and send message + # Create and send message with metadata message = create_test_message(text='Hello', message_id='msg-1') - request = send_message_request(message, req_id='req-1') + metadata = {'user_id': '123'} + request = send_message_request(message, req_id='req-1', metadata=metadata) response = await tm.send_message(request) @@ -200,8 +211,10 @@ async def test_send_message( assert 'id' in task assert 'context_id' in task - # Verify broker.run_task was called + # Verify broker.run_task was called with metadata mock_run_task.assert_called_once() + call_args = mock_run_task.call_args[0][0] + assert call_args.get('metadata') == metadata @pytest.mark.asyncio @@ -436,11 +449,12 @@ async def worker_task(): asyncio.create_task(worker_task()) # Mock broker.run_task with side effect - mocker.patch.object(broker, 'run_task', new_callable=AsyncMock, side_effect=simulate_worker) + mock_run_task = mocker.patch.object(broker, 'run_task', new_callable=AsyncMock, side_effect=simulate_worker) - # Create stream request + # Create stream request with metadata message = create_test_message(text='Hello streaming', message_id='msg-12') - request = stream_message_request(message, req_id='req-12') + metadata = {'session_id': 'stream-123'} + request = stream_message_request(message, req_id='req-12', metadata=metadata) # Collect events events: list[Any] = [] @@ -456,6 +470,11 @@ async def worker_task(): assert events[2]['status']['state'] == 'completed' assert events[2]['final'] is True + # Verify metadata was passed to broker + mock_run_task.assert_called_once() + call_args = mock_run_task.call_args[0][0] + assert call_args.get('metadata') == metadata + @pytest.mark.asyncio async def test_stream_message_with_context_and_history(