diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py index 657e78aca..976b12588 100644 --- a/src/a2a/client/base_client.py +++ b/src/a2a/client/base_client.py @@ -18,8 +18,11 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, Message, @@ -247,6 +250,45 @@ async def get_task_callback( request, context=context, extensions=extensions ) + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task. + + Args: + request: The `ListTaskPushNotificationConfigRequest` object specifying the request. + context: The client call context. + extensions: List of extensions to be activated. + + Returns: + A `ListTaskPushNotificationConfigResponse` object. + """ + return await self._transport.list_task_callback( + request, context=context, extensions=extensions + ) + + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task. + + Args: + request: The `DeleteTaskPushNotificationConfigRequest` object specifying the request. + context: The client call context. + extensions: List of extensions to be activated. + """ + await self._transport.delete_task_callback( + request, context=context, extensions=extensions + ) + async def subscribe( self, request: SubscribeToTaskRequest, diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index cad49173d..8ac77e118 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -13,8 +13,11 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, Message, @@ -175,6 +178,26 @@ async def get_task_callback( ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" + @abstractmethod + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task.""" + + @abstractmethod + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task.""" + @abstractmethod async def subscribe( self, diff --git a/src/a2a/client/transports/base.py b/src/a2a/client/transports/base.py index 933b10c66..6c8506408 100644 --- a/src/a2a/client/transports/base.py +++ b/src/a2a/client/transports/base.py @@ -9,8 +9,11 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, SendMessageRequest, @@ -110,6 +113,26 @@ async def get_task_callback( ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" + @abstractmethod + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task.""" + + @abstractmethod + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task.""" + @abstractmethod async def subscribe( self, diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index c73cf8faa..47df4958d 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -23,8 +23,11 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, SendMessageRequest, @@ -198,6 +201,32 @@ async def get_task_callback( metadata=self._get_grpc_metadata(extensions), ) + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task.""" + return await self.stub.ListTaskPushNotificationConfig( + request, + metadata=self._get_grpc_metadata(extensions), + ) + + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task.""" + await self.stub.DeleteTaskPushNotificationConfig( + request, + metadata=self._get_grpc_metadata(extensions), + ) + async def get_extended_agent_card( self, *, diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index 9dea30ba3..8f32cb4c6 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -25,9 +25,12 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, SendMessageRequest, @@ -378,6 +381,69 @@ async def get_task_callback( ) return response + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task.""" + rpc_request = JSONRPC20Request( + method='ListTaskPushNotificationConfig', + params=json_format.MessageToDict(request), + _id=str(uuid4()), + ) + modified_kwargs = update_extension_header( + self._get_http_args(context), + extensions if extensions is not None else self.extensions, + ) + payload, modified_kwargs = await self._apply_interceptors( + 'ListTaskPushNotificationConfig', + cast('dict[str, Any]', rpc_request.data), + modified_kwargs, + context, + ) + response_data = await self._send_request(payload, modified_kwargs) + json_rpc_response = JSONRPC20Response(**response_data) + if json_rpc_response.error: + raise A2AClientJSONRPCError(json_rpc_response.error) + response: ListTaskPushNotificationConfigResponse = ( + json_format.ParseDict( + json_rpc_response.result, + ListTaskPushNotificationConfigResponse(), + ) + ) + return response + + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task.""" + rpc_request = JSONRPC20Request( + method='DeleteTaskPushNotificationConfig', + params=json_format.MessageToDict(request), + _id=str(uuid4()), + ) + modified_kwargs = update_extension_header( + self._get_http_args(context), + extensions if extensions is not None else self.extensions, + ) + payload, modified_kwargs = await self._apply_interceptors( + 'DeleteTaskPushNotificationConfig', + cast('dict[str, Any]', rpc_request.data), + modified_kwargs, + context, + ) + response_data = await self._send_request(payload, modified_kwargs) + json_rpc_response = JSONRPC20Response(**response_data) + if json_rpc_response.error: + raise A2AClientJSONRPCError(json_rpc_response.error) + async def subscribe( self, request: SubscribeToTaskRequest, diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index 316231c4a..b93ecfc5d 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -23,8 +23,11 @@ AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, SendMessageRequest, @@ -224,6 +227,21 @@ async def _send_get_request( ) ) + async def _send_delete_request( + self, + target: str, + query_params: dict[str, Any], + http_kwargs: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return await self._send_request( + self.httpx_client.build_request( + 'DELETE', + f'{self.url}{target}', + params=query_params, + **(http_kwargs or {}), + ) + ) + async def get_task( self, request: GetTaskRequest, @@ -363,6 +381,64 @@ async def get_task_callback( ) return response + async def list_task_callback( + self, + request: ListTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> ListTaskPushNotificationConfigResponse: + """Lists push notification configurations for a specific task.""" + params = MessageToDict(request) + modified_kwargs = update_extension_header( + self._get_http_args(context), + extensions if extensions is not None else self.extensions, + ) + params, modified_kwargs = await self._apply_interceptors( + params, + modified_kwargs, + context, + ) + if 'task_id' in params: + del params['task_id'] + response_data = await self._send_get_request( + f'/v1/tasks/{request.task_id}/pushNotificationConfigs', + params, + modified_kwargs, + ) + response: ListTaskPushNotificationConfigResponse = ParseDict( + response_data, ListTaskPushNotificationConfigResponse() + ) + return response + + async def delete_task_callback( + self, + request: DeleteTaskPushNotificationConfigRequest, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, + ) -> None: + """Deletes the push notification configuration for a specific task.""" + params = MessageToDict(request) + modified_kwargs = update_extension_header( + self._get_http_args(context), + extensions if extensions is not None else self.extensions, + ) + params, modified_kwargs = await self._apply_interceptors( + params, + modified_kwargs, + context, + ) + if 'id' in params: + del params['id'] + if 'task_id' in params: + del params['task_id'] + await self._send_delete_request( + f'/v1/tasks/{request.task_id}/pushNotificationConfigs/{request.id}', + params, + modified_kwargs, + ) + async def subscribe( self, request: SubscribeToTaskRequest, diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 8807f7ef5..3c1d1fc35 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -234,6 +234,12 @@ def routes(self) -> dict[tuple[str, str], Callable[[Request], Any]]: ): functools.partial( self._handle_request, self.handler.get_push_notification ), + ( + '/v1/tasks/{id}/pushNotificationConfigs/{push_id}', + 'DELETE', + ): functools.partial( + self._handle_request, self.handler.delete_push_notification + ), ( '/v1/tasks/{id}/pushNotificationConfigs', 'POST', diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index 4735ebc53..07bad1807 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -18,6 +18,8 @@ from collections.abc import Callable +from google.protobuf import empty_pb2 + import a2a.types.a2a_pb2_grpc as a2a_grpc from a2a import types @@ -292,6 +294,55 @@ async def CreateTaskPushNotificationConfig( await self.abort_context(e, context) return a2a_pb2.TaskPushNotificationConfig() + async def ListTaskPushNotificationConfig( + self, + request: a2a_pb2.ListTaskPushNotificationConfigRequest, + context: grpc.aio.ServicerContext, + ) -> a2a_pb2.ListTaskPushNotificationConfigResponse: + """Handles the 'ListTaskPushNotificationConfig' gRPC method. + + Args: + request: The incoming `ListTaskPushNotificationConfigRequest` object. + context: Context provided by the server. + + Returns: + A `ListTaskPushNotificationConfigResponse` object containing the configs. + """ + try: + server_context = self.context_builder.build(context) + return await self.request_handler.on_list_task_push_notification_config( + request, + server_context, + ) + except ServerError as e: + await self.abort_context(e, context) + return a2a_pb2.ListTaskPushNotificationConfigResponse() + + async def DeleteTaskPushNotificationConfig( + self, + request: a2a_pb2.DeleteTaskPushNotificationConfigRequest, + context: grpc.aio.ServicerContext, + ) -> empty_pb2.Empty: + """Handles the 'DeleteTaskPushNotificationConfig' gRPC method. + + Args: + request: The incoming `DeleteTaskPushNotificationConfigRequest` object. + context: Context provided by the server. + + Returns: + An empty `Empty` object. + """ + try: + server_context = self.context_builder.build(context) + await self.request_handler.on_delete_task_push_notification_config( + request, + server_context, + ) + return empty_pb2.Empty() + except ServerError as e: + await self.abort_context(e, context) + return empty_pb2.Empty() + async def GetTask( self, request: a2a_pb2.GetTaskRequest, diff --git a/src/a2a/server/request_handlers/rest_handler.py b/src/a2a/server/request_handlers/rest_handler.py index 61e063570..aae6a25d2 100644 --- a/src/a2a/server/request_handlers/rest_handler.py +++ b/src/a2a/server/request_handlers/rest_handler.py @@ -256,6 +256,30 @@ async def on_get_task( return MessageToDict(task) raise ServerError(error=TaskNotFoundError()) + async def delete_push_notification( + self, + request: Request, + context: ServerCallContext, + ) -> dict[str, Any]: + """Handles the 'tasks/pushNotificationConfig/delete' REST method. + + Args: + request: The incoming `Request` object. + context: Context provided by the server. + + Returns: + An empty `dict` representing the empty response. + """ + task_id = request.path_params['id'] + push_id = request.path_params['push_id'] + params = a2a_pb2.DeleteTaskPushNotificationConfigRequest( + task_id=task_id, id=push_id + ) + await self.request_handler.on_delete_task_push_notification_config( + params, context + ) + return {} + async def list_tasks( self, request: Request, @@ -263,17 +287,12 @@ async def list_tasks( ) -> dict[str, Any]: """Handles the 'tasks/list' REST method. - This method is currently not implemented. - Args: request: The incoming `Request` object. context: Context provided by the server. Returns: A list of `dict` representing the `Task` objects. - - Raises: - NotImplementedError: This method is not yet implemented. """ params = a2a_pb2.ListTasksRequest() # Parse query params, keeping arrays/repeated fields in mind if there are any @@ -292,16 +311,24 @@ async def list_push_notifications( ) -> dict[str, Any]: """Handles the 'tasks/pushNotificationConfig/list' REST method. - This method is currently not implemented. - Args: request: The incoming `Request` object. context: Context provided by the server. Returns: A list of `dict` representing the `TaskPushNotificationConfig` objects. - - Raises: - NotImplementedError: This method is not yet implemented. """ - raise NotImplementedError('list notifications not implemented') + task_id = request.path_params['id'] + params = a2a_pb2.ListTaskPushNotificationConfigRequest(task_id=task_id) + + # Parse query params, keeping arrays/repeated fields in mind if there are any + ParseDict( + dict(request.query_params), params, ignore_unknown_fields=True + ) + + result = ( + await self.request_handler.on_list_task_push_notification_config( + params, context + ) + ) + return MessageToDict(result) diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 9632a335f..7b887ee1d 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -12,14 +12,17 @@ AgentCard, Artifact, AuthenticationInfo, + CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, GetTaskRequest, Message, Part, PushNotificationConfig, Role, SendMessageRequest, - CreateTaskPushNotificationConfigRequest, Task, TaskArtifactUpdateEvent, TaskPushNotificationConfig, @@ -42,6 +45,8 @@ def mock_grpc_stub() -> AsyncMock: stub.CancelTask = AsyncMock() stub.CreateTaskPushNotificationConfig = AsyncMock() stub.GetTaskPushNotificationConfig = AsyncMock() + stub.ListTaskPushNotificationConfig = AsyncMock() + stub.DeleteTaskPushNotificationConfig = AsyncMock() return stub @@ -531,6 +536,68 @@ async def test_get_task_callback_with_invalid_task( assert response.task_id == 'invalid-path-to-task-1' +@pytest.mark.asyncio +async def test_list_task_callback( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +) -> None: + """Test retrieving task push notification configs.""" + mock_grpc_stub.ListTaskPushNotificationConfig.return_value = ( + a2a_pb2.ListTaskPushNotificationConfigResponse( + configs=[sample_task_push_notification_config] + ) + ) + + response = await grpc_transport.list_task_callback( + ListTaskPushNotificationConfigRequest(task_id='task-1') + ) + + mock_grpc_stub.ListTaskPushNotificationConfig.assert_awaited_once_with( + a2a_pb2.ListTaskPushNotificationConfigRequest(task_id='task-1'), + metadata=[ + ( + HTTP_EXTENSION_HEADER.lower(), + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], + ) + assert len(response.configs) == 1 + assert response.configs[0].task_id == 'task-1' + + +@pytest.mark.asyncio +async def test_delete_task_callback( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +) -> None: + """Test deleting task push notification config.""" + mock_grpc_stub.DeleteTaskPushNotificationConfig.return_value = ( + sample_task_push_notification_config + ) + + await grpc_transport.delete_task_callback( + DeleteTaskPushNotificationConfigRequest( + task_id='task-1', + id='config-1', + ) + ) + + mock_grpc_stub.DeleteTaskPushNotificationConfig.assert_awaited_once_with( + a2a_pb2.DeleteTaskPushNotificationConfigRequest( + task_id='task-1', + id='config-1', + ), + metadata=[ + ( + HTTP_EXTENSION_HEADER.lower(), + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], + ) + + @pytest.mark.parametrize( 'initial_extensions, input_extensions, expected_metadata', [ diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py index f14ab9fa3..b6758ecad 100644 --- a/tests/client/transports/test_jsonrpc_client.py +++ b/tests/client/transports/test_jsonrpc_client.py @@ -23,14 +23,17 @@ AgentInterface, AgentCard, CancelTaskRequest, + CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, GetTaskRequest, Message, Part, SendMessageConfiguration, SendMessageRequest, SendMessageResponse, - CreateTaskPushNotificationConfigRequest, Task, TaskPushNotificationConfig, TaskState, @@ -375,6 +378,73 @@ async def test_get_task_callback_success( payload = call_args[1]['json'] assert payload['method'] == 'GetTaskPushNotificationConfig' + @pytest.mark.asyncio + async def test_list_task_callback_success( + self, transport, mock_httpx_client + ): + """Test successful task multiple callbacks retrieval.""" + task_id = str(uuid4()) + mock_response = MagicMock() + mock_response.json.return_value = { + 'jsonrpc': '2.0', + 'id': '1', + 'result': { + 'configs': [ + { + 'task_id': f'{task_id}', + 'id': 'config-1', + 'push_notification_config': { + 'id': 'config-1', + 'url': 'https://example.com', + }, + } + ] + }, + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + request = ListTaskPushNotificationConfigRequest( + task_id=f'{task_id}', + ) + response = await transport.list_task_callback(request) + + assert len(response.configs) == 1 + assert response.configs[0].task_id == task_id + call_args = mock_httpx_client.post.call_args + payload = call_args[1]['json'] + assert payload['method'] == 'ListTaskPushNotificationConfig' + + @pytest.mark.asyncio + async def test_delete_task_callback_success( + self, transport, mock_httpx_client + ): + """Test successful task callback deletion.""" + task_id = str(uuid4()) + mock_response = MagicMock() + mock_response.json.return_value = { + 'jsonrpc': '2.0', + 'id': '1', + 'result': { + 'task_id': f'{task_id}', + 'id': 'config-1', + }, + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + request = DeleteTaskPushNotificationConfigRequest( + task_id=f'{task_id}', + id='config-1', + ) + response = await transport.delete_task_callback(request) + + mock_httpx_client.post.assert_called_once() # Assuming mock_send_request was a typo for mock_httpx_client.post + assert response is None + call_args = mock_httpx_client.post.call_args + payload = call_args[1]['json'] + assert payload['method'] == 'DeleteTaskPushNotificationConfig' + class TestClose: """Tests for the close method.""" diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py index 8a5f3c620..eac58728a 100644 --- a/tests/client/transports/test_rest_client.py +++ b/tests/client/transports/test_rest_client.py @@ -15,8 +15,12 @@ AgentCapabilities, AgentCard, AgentInterface, + DeleteTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, Role, SendMessageRequest, + TaskPushNotificationConfig, ) from a2a.utils.constants import TRANSPORT_HTTP_JSON @@ -309,3 +313,92 @@ async def test_get_card_with_extended_card_support_with_extensions( 'https://example.com/test-ext/v2', }, ) + + +class TestTaskCallback: + """Tests for the task callback methods.""" + + @pytest.mark.asyncio + async def test_list_task_callback_success( + self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock + ): + """Test successful task multiple callbacks retrieval.""" + client = RestTransport( + httpx_client=mock_httpx_client, agent_card=mock_agent_card + ) + task_id = 'task-1' + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + 'configs': [ + { + 'taskId': task_id, + 'id': 'config-1', + 'pushNotificationConfig': { + 'id': 'config-1', + 'url': 'https://example.com', + }, + } + ] + } + mock_httpx_client.send.return_value = mock_response + + # Mock the build_request method to capture its inputs + mock_build_request = MagicMock( + return_value=AsyncMock(spec=httpx.Request) + ) + mock_httpx_client.build_request = mock_build_request + + request = ListTaskPushNotificationConfigRequest( + task_id=task_id, + ) + response = await client.list_task_callback(request) + + assert len(response.configs) == 1 + assert response.configs[0].task_id == task_id + + mock_build_request.assert_called_once() + call_args = mock_build_request.call_args + assert call_args[0][0] == 'GET' + assert f'/v1/tasks/{task_id}/pushNotificationConfigs' in call_args[0][1] + + @pytest.mark.asyncio + async def test_delete_task_callback_success( + self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock + ): + """Test successful task callback deletion.""" + client = RestTransport( + httpx_client=mock_httpx_client, agent_card=mock_agent_card + ) + task_id = 'task-1' + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + 'taskId': task_id, + 'id': 'config-1', + 'pushNotificationConfig': { + 'id': 'config-1', + 'url': 'https://example.com', + }, + } + mock_httpx_client.send.return_value = mock_response + + # Mock the build_request method to capture its inputs + mock_build_request = MagicMock( + return_value=AsyncMock(spec=httpx.Request) + ) + mock_httpx_client.build_request = mock_build_request + + request = DeleteTaskPushNotificationConfigRequest( + task_id=task_id, + id='config-1', + ) + await client.delete_task_callback(request) + + mock_build_request.assert_called_once() + call_args = mock_build_request.call_args + assert call_args[0][0] == 'DELETE' + assert ( + f'/v1/tasks/{task_id}/pushNotificationConfigs/config-1' + in call_args[0][1] + ) diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index 3299af1d6..1d33df8a9 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -41,6 +41,9 @@ Role, SendMessageRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, SubscribeToTaskRequest, Task, TaskPushNotificationConfig, @@ -135,6 +138,12 @@ async def stream_side_effect(*args, **kwargs): CALLBACK_CONFIG ) handler.on_get_task_push_notification_config.return_value = CALLBACK_CONFIG + handler.on_list_task_push_notification_config.return_value = ( + ListTaskPushNotificationConfigResponse(configs=[CALLBACK_CONFIG]) + ) + handler.on_delete_task_push_notification_config.return_value = ( + CALLBACK_CONFIG + ) async def resubscribe_side_effect(*args, **kwargs): yield RESUBSCRIBE_EVENT @@ -714,6 +723,112 @@ def channel_factory(address: str) -> Channel: await transport.close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'transport_setup_fixture', + [ + pytest.param('jsonrpc_setup', id='JSON-RPC'), + pytest.param('rest_setup', id='REST'), + ], +) +async def test_http_transport_list_task_callback( + transport_setup_fixture: str, request +) -> None: + transport_setup: TransportSetup = request.getfixturevalue( + transport_setup_fixture + ) + transport = transport_setup.transport + handler = transport_setup.handler + + params = ListTaskPushNotificationConfigRequest( + task_id=f'{CALLBACK_CONFIG.task_id}', + ) + result = await transport.list_task_callback(request=params) + + assert len(result.configs) == 1 + assert result.configs[0].task_id == CALLBACK_CONFIG.task_id + handler.on_list_task_push_notification_config.assert_awaited_once() + + if hasattr(transport, 'close'): + await transport.close() + + +@pytest.mark.asyncio +async def test_grpc_transport_list_task_callback( + grpc_server_and_handler: tuple[str, AsyncMock], + agent_card: AgentCard, +) -> None: + server_address, handler = grpc_server_and_handler + + def channel_factory(address: str) -> Channel: + return grpc.aio.insecure_channel(address) + + channel = channel_factory(server_address) + transport = GrpcTransport(channel=channel, agent_card=agent_card) + + params = ListTaskPushNotificationConfigRequest( + task_id=f'{CALLBACK_CONFIG.task_id}', + ) + result = await transport.list_task_callback(request=params) + + assert len(result.configs) == 1 + assert result.configs[0].task_id == CALLBACK_CONFIG.task_id + handler.on_list_task_push_notification_config.assert_awaited_once() + + await transport.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'transport_setup_fixture', + [ + pytest.param('jsonrpc_setup', id='JSON-RPC'), + pytest.param('rest_setup', id='REST'), + ], +) +async def test_http_transport_delete_task_callback( + transport_setup_fixture: str, request +) -> None: + transport_setup: TransportSetup = request.getfixturevalue( + transport_setup_fixture + ) + transport = transport_setup.transport + handler = transport_setup.handler + + params = DeleteTaskPushNotificationConfigRequest( + task_id=f'{CALLBACK_CONFIG.task_id}', id=CALLBACK_CONFIG.id + ) + await transport.delete_task_callback(request=params) + + handler.on_delete_task_push_notification_config.assert_awaited_once() + + if hasattr(transport, 'close'): + await transport.close() + + +@pytest.mark.asyncio +async def test_grpc_transport_delete_task_callback( + grpc_server_and_handler: tuple[str, AsyncMock], + agent_card: AgentCard, +) -> None: + server_address, handler = grpc_server_and_handler + + def channel_factory(address: str) -> Channel: + return grpc.aio.insecure_channel(address) + + channel = channel_factory(server_address) + transport = GrpcTransport(channel=channel, agent_card=agent_card) + + params = DeleteTaskPushNotificationConfigRequest( + task_id=f'{CALLBACK_CONFIG.task_id}', id=CALLBACK_CONFIG.id + ) + await transport.delete_task_callback(request=params) + + handler.on_delete_task_push_notification_config.assert_awaited_once() + + await transport.close() + + @pytest.mark.asyncio @pytest.mark.parametrize( 'transport_setup_fixture',