-
Notifications
You must be signed in to change notification settings - Fork 326
test: push notification e2e tests #486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
d83a7fc
feat: push notifications e2e tests
30a10c7
fix: issues flaged by gemini
6b6cc9c
fix: add uvicorn to dev dependecies
852ef93
fix: avoid reliance on notification order
a6a8c6a
Resolve Mike's comments.
771c1ec
Merge branch 'main' into push-notifications
lkawka 78a58cf
Verify that notification tokens were properly passed around
4a596b5
Remove port lock
3bcd1da
Merge branch 'main' into push-notifications
lkawka File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -103,6 +103,7 @@ dev = [ | |
| "autoflake", | ||
| "no_implicit_optional", | ||
| "trio", | ||
| "uvicorn>=0.35.0", | ||
| ] | ||
|
|
||
| [[tool.uv.index]] | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import httpx | ||
|
|
||
| from fastapi import FastAPI | ||
|
|
||
| from a2a.server.agent_execution import AgentExecutor, RequestContext | ||
| from a2a.server.apps import A2ARESTFastAPIApplication | ||
| from a2a.server.events import EventQueue | ||
| from a2a.server.request_handlers import DefaultRequestHandler | ||
| from a2a.server.tasks import ( | ||
| BasePushNotificationSender, | ||
| InMemoryPushNotificationConfigStore, | ||
| InMemoryTaskStore, | ||
| TaskUpdater, | ||
| ) | ||
| from a2a.types import ( | ||
| AgentCapabilities, | ||
| AgentCard, | ||
| AgentSkill, | ||
| InvalidParamsError, | ||
| Message, | ||
| Task, | ||
| ) | ||
| from a2a.utils import ( | ||
| new_agent_text_message, | ||
| new_task, | ||
| ) | ||
| from a2a.utils.errors import ServerError | ||
|
|
||
|
|
||
| def test_agent_card(url: str) -> AgentCard: | ||
| """Returns an agent card for the test agent.""" | ||
| return AgentCard( | ||
| name='Test Agent', | ||
| description='Just a test agent', | ||
| url=url, | ||
| version='1.0.0', | ||
| default_input_modes=['text'], | ||
| default_output_modes=['text'], | ||
| capabilities=AgentCapabilities(streaming=True, push_notifications=True), | ||
| skills=[ | ||
| AgentSkill( | ||
| id='greeting', | ||
| name='Greeting Agent', | ||
| description='just greets the user', | ||
| tags=['greeting'], | ||
| examples=['Hello Agent!', 'How are you?'], | ||
| ) | ||
| ], | ||
| supports_authenticated_extended_card=True, | ||
| ) | ||
|
|
||
|
|
||
| class TestAgent: | ||
| """Agent for push notification testing.""" | ||
|
|
||
| async def invoke( | ||
| self, updater: TaskUpdater, msg: Message, task: Task | ||
| ) -> None: | ||
| # Fail for unsupported messages. | ||
| if ( | ||
| not msg.parts | ||
| or len(msg.parts) != 1 | ||
| or msg.parts[0].root.kind != 'text' | ||
| ): | ||
| await updater.failed( | ||
| new_agent_text_message( | ||
| 'Unsupported message.', task.context_id, task.id | ||
| ) | ||
| ) | ||
| return | ||
| text_message = msg.parts[0].root.text | ||
|
|
||
| # Simple request-response flow. | ||
| if text_message == 'Hello Agent!': | ||
| await updater.complete( | ||
| new_agent_text_message('Hello User!', task.context_id, task.id) | ||
| ) | ||
|
|
||
| # Flow with user input required: "How are you?" -> "Good! How are you?" -> "Good" -> "Amazing". | ||
| elif text_message == 'How are you?': | ||
| await updater.requires_input( | ||
| new_agent_text_message( | ||
| 'Good! How are you?', task.context_id, task.id | ||
| ) | ||
| ) | ||
| elif text_message == 'Good': | ||
| await updater.complete( | ||
| new_agent_text_message('Amazing', task.context_id, task.id) | ||
| ) | ||
|
|
||
| # Fail for unsupported messages. | ||
| else: | ||
| await updater.failed( | ||
| new_agent_text_message( | ||
| 'Unsupported message.', task.context_id, task.id | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| class TestAgentExecutor(AgentExecutor): | ||
| """Test AgentExecutor implementation.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self.agent = TestAgent() | ||
|
|
||
| async def execute( | ||
| self, | ||
| context: RequestContext, | ||
| event_queue: EventQueue, | ||
| ) -> None: | ||
| if not context.message: | ||
| raise ServerError(error=InvalidParamsError(message='No message')) | ||
|
|
||
| task = context.current_task | ||
| if not task: | ||
| task = new_task(context.message) | ||
| await event_queue.enqueue_event(task) | ||
| updater = TaskUpdater(event_queue, task.id, task.context_id) | ||
|
|
||
| await self.agent.invoke(updater, context.message, task) | ||
|
|
||
| async def cancel( | ||
| self, context: RequestContext, event_queue: EventQueue | ||
| ) -> None: | ||
| raise NotImplementedError('cancel not supported') | ||
|
|
||
|
|
||
| def create_agent_app( | ||
| url: str, notification_client: httpx.AsyncClient | ||
| ) -> FastAPI: | ||
| """Creates a new HTTP+REST FastAPI application for the test agent.""" | ||
| push_config_store = InMemoryPushNotificationConfigStore() | ||
| app = A2ARESTFastAPIApplication( | ||
| agent_card=test_agent_card(url), | ||
| http_handler=DefaultRequestHandler( | ||
| agent_executor=TestAgentExecutor(), | ||
| task_store=InMemoryTaskStore(), | ||
| push_config_store=push_config_store, | ||
| push_sender=BasePushNotificationSender( | ||
| httpx_client=notification_client, | ||
| config_store=push_config_store, | ||
| ), | ||
| ), | ||
| ) | ||
| return app.build() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import asyncio | ||
|
|
||
| from typing import Annotated | ||
|
|
||
| from fastapi import FastAPI, HTTPException, Path, Request | ||
| from pydantic import BaseModel, ValidationError | ||
|
|
||
| from a2a.types import Task | ||
|
|
||
|
|
||
| class Notification(BaseModel): | ||
| """Encapsulates default push notification data.""" | ||
|
|
||
| task: Task | ||
| token: str | ||
|
|
||
|
|
||
| def create_notifications_app() -> FastAPI: | ||
| """Creates a simple push notification ingesting HTTP+REST application.""" | ||
| app = FastAPI() | ||
| store_lock = asyncio.Lock() | ||
| store: dict[str, list[Notification]] = {} | ||
|
|
||
| @app.post('/notifications') | ||
| async def add_notification(request: Request): | ||
| """Endpoint for injesting notifications from agents. It receives a JSON | ||
| payload and stores it in-memory. | ||
| """ | ||
| token = request.headers.get('x-a2a-notification-token') | ||
| if not token: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail='Missing "x-a2a-notification-token" header.', | ||
| ) | ||
| try: | ||
| task = Task.model_validate(await request.json()) | ||
| except ValidationError as e: | ||
| raise HTTPException(status_code=400, detail=str(e)) | ||
|
|
||
| async with store_lock: | ||
| if task.id not in store: | ||
| store[task.id] = [] | ||
| store[task.id].append( | ||
| Notification( | ||
| task=task, | ||
| token=token, | ||
| ) | ||
| ) | ||
| return { | ||
| 'status': 'received', | ||
| } | ||
|
|
||
| @app.get('/tasks/{task_id}/notifications') | ||
| async def list_notifications_by_task( | ||
| task_id: Annotated[ | ||
| str, Path(title='The ID of the task to list the notifications for.') | ||
| ], | ||
| ): | ||
| """Helper endpoint for retrieving injested notifications for a given task.""" | ||
| async with store_lock: | ||
| notifications = store.get(task_id, []) | ||
| return {'notifications': notifications} | ||
|
|
||
| @app.get('/health') | ||
| def health_check(): | ||
| """Helper endpoint for checking if the server is up.""" | ||
| return {'status': 'ok'} | ||
|
|
||
| return app | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.