Decouple platform tools from the integrations API (#7180)

* Decouple platform tools from the integrations API

Define normalized selector and tool data so platform tool creation does
not depend on the legacy API response shape. This contract makes the
legacy client easier to replace later.

- Move action discovery and response normalization into LegacyClient.
- Pass ToolInfo from discovery through tool creation and execution.
- Replace the builder flow with direct factory orchestration.
- Preserve app, action, and connection data in immutable models.
- Build sanitized tool names from the full tool identity.
- Preserve legacy request, SSL, and failure behavior with contract tests.

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API
This commit is contained in:
Vinicius Brasil
2026-09-01 13:07:08 -03:00
committed by GitHub
parent 917b9df6d7
commit 48cc5d4e5e
12 changed files with 792 additions and 700 deletions

View File

@@ -7,24 +7,12 @@ through the CrewAI platform API.
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
)
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
CrewaiPlatformToolBuilder,
)
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
CrewaiPlatformTools,
)
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
IntegrationsClient,
IntegrationsResponse,
LegacyClient,
)
__all__ = [
"CrewAIPlatformActionTool",
"CrewaiPlatformToolBuilder",
"CrewaiPlatformTools",
"IntegrationsClient",
"IntegrationsResponse",
"LegacyClient",
]

View File

@@ -1,54 +0,0 @@
from uuid import UUID
class ApplicationSelector:
"""Parse an application selector.
Selectors use the ``application[/action][@connection_uuid]`` syntax.
Raises:
ValueError: If the selector does not follow the supported syntax.
"""
name: str
action: str | None
connection_id: UUID | None
def __init__(self, value: str) -> None:
if not value:
raise ValueError(f"Invalid application selector {value!r}: cannot be empty")
if "@" in value and "/" in value and value.index("@") < value.index("/"):
raise ValueError(
f"Invalid application selector {value!r}: "
"connection ID must be the last segment"
)
app, connection_separator, connection_id = value.partition("@")
name, action_separator, action = app.partition("/")
if not name:
raise ValueError(
f"Invalid application selector {value!r}: application cannot be empty"
)
if action_separator and not action:
raise ValueError(
f"Invalid application selector {value!r}: action cannot be empty"
)
if connection_separator and not connection_id:
raise ValueError(
f"Invalid application selector {value!r}: connection ID cannot be empty"
)
parsed_connection_id = None
if connection_id:
try:
parsed_connection_id = UUID(connection_id)
except ValueError as error:
raise ValueError(
f"Invalid application selector {value!r}: "
"connection ID must be a valid UUID"
) from error
self.name = name
self.action = action if action_separator else None
self.connection_id = parsed_connection_id

View File

@@ -11,85 +11,66 @@ from pydantic import Field, PrivateAttr, create_model
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
IntegrationsClient,
LegacyClient,
ToolExecutionFailure,
ToolInfo,
)
class CrewAIPlatformActionTool(BaseTool):
_integrations_client: IntegrationsClient = PrivateAttr()
_client: IntegrationsClient = PrivateAttr()
_tool_info: ToolInfo = PrivateAttr()
app: str = Field(description="The integration slug for this action")
action_name: str = Field(default="", description="The name of the action")
action_schema: dict[str, Any] = Field(
default_factory=dict, description="The schema of the action"
)
def __init__(
self,
description: str,
app: str,
action_name: str,
action_schema: dict[str, Any],
integrations_client: IntegrationsClient | None = None,
tool_info: ToolInfo,
client: IntegrationsClient | None = None,
) -> None:
parameters = action_schema.get("function", {}).get("parameters", {})
schema_name = f"{tool_info.qualified_name}Schema"
parameters = tool_info.parameters
if parameters and parameters.get("properties"):
try:
if "title" not in parameters:
parameters = {**parameters, "title": f"{action_name}Schema"}
parameters = {**parameters, "title": schema_name}
if "type" not in parameters:
parameters = {**parameters, "type": "object"}
args_schema = create_model_from_schema(parameters)
except Exception:
args_schema = create_model(f"{action_name}Schema")
args_schema = create_model(schema_name)
else:
args_schema = create_model(f"{action_name}Schema")
args_schema = create_model(schema_name)
super().__init__(
name=action_name.lower().replace(" ", "_"),
description=description,
name=tool_info.qualified_name,
description=tool_info.description,
args_schema=args_schema,
app=app,
)
self.action_name = action_name
self.action_schema = action_schema
self._integrations_client = (
integrations_client if integrations_client is not None else LegacyClient()
app=tool_info.app,
)
self._client = client if client is not None else LegacyClient()
self._tool_info = tool_info
def _run(self, **kwargs: Any) -> Any:
def _run(self, **kwargs: Any) -> str | ToolFailure:
try:
cleaned_kwargs = {
key: value for key, value in kwargs.items() if value is not None
}
response = self._integrations_client.execute_action(
self.action_name, cleaned_kwargs
)
result = self._client.execute_action(self._tool_info, cleaned_kwargs)
data = response.json()
if not response.ok:
if isinstance(data, dict):
error_info = data.get("error", {})
if isinstance(error_info, dict):
error_message = error_info.get("message", json.dumps(data))
else:
error_message = str(error_info)
else:
error_message = str(data)
# A non-2xx here means the upstream app rejected the action
# (e.g. Slack's channel_not_found) -- report it, not prose.
if isinstance(result, ToolExecutionFailure):
return ToolFailure(
message=f"API request failed: {error_message}",
code=str(response.status_code),
retryable=response.status_code >= 500,
details={"action": self.action_name},
message=f"API request failed: {result.message}",
code=result.code,
retryable=result.retryable,
details={"action": self._tool_info.action},
)
return json.dumps(data, indent=2)
return json.dumps(result.output, indent=2)
except Exception as e:
return ToolFailure(
message=f"Error executing action {self.action_name}: {e!s}",
message=f"Error executing action {self._tool_info.action}: {e!s}",
code=e.__class__.__name__,
details={"action": self.action_name},
details={"action": self._tool_info.action},
)

View File

@@ -1,115 +0,0 @@
"""CrewAI platform tool builder for fetching and creating action tools."""
import logging
from types import TracebackType
from typing import Any
from crewai.tools import BaseTool
from crewai_tools.tools.crewai_platform_tools.application_selector import (
ApplicationSelector,
)
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
)
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
IntegrationsClient,
LegacyClient,
)
logger = logging.getLogger(__name__)
class CrewaiPlatformToolBuilder:
"""Builds platform tools from remote action schemas."""
def __init__(
self,
apps: list[str],
integrations_client: IntegrationsClient | None = None,
) -> None:
self._apps = [ApplicationSelector(app) for app in apps]
self._integrations_client = (
integrations_client if integrations_client is not None else LegacyClient()
)
self._actions_schema: dict[str, dict[str, Any]] = {}
self._tools: list[BaseTool] | None = None
def tools(self) -> list[BaseTool]:
"""Fetch actions and return built tools."""
if self._tools is None:
self._fetch_actions()
self._create_tools()
return self._tools if self._tools is not None else []
def _fetch_actions(self) -> None:
"""Fetch action schemas from the platform API."""
apps = [
f"{app.name}/{app.action}" if app.action is not None else app.name
for app in self._apps
]
try:
response = self._integrations_client.get_actions(apps)
response.raise_for_status()
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to fetch platform tools for apps {apps}: {e}")
return
raw_data = response.json()
self._actions_schema = {}
action_categories = raw_data.get("actions", {})
for app, action_list in action_categories.items():
if isinstance(action_list, list):
for action in action_list:
if not isinstance(action, dict):
continue
if action_name := action.get("name"):
action_schema = {
"function": {
"name": action_name,
"description": action.get(
"description", f"Execute {action_name}"
),
"parameters": action.get("parameters", {}),
"app": app,
}
}
self._actions_schema[action_name] = action_schema
def _create_tools(self) -> None:
"""Create tool instances from fetched action schemas."""
tools: list[BaseTool] = []
for action_name, action_schema in self._actions_schema.items():
function_details = action_schema.get("function", {})
description = function_details.get("description", f"Execute {action_name}")
tool = CrewAIPlatformActionTool(
description=description,
app=function_details["app"],
action_name=action_name,
action_schema=action_schema,
integrations_client=self._integrations_client,
)
tools.append(tool)
self._tools = tools
def __enter__(self) -> list[BaseTool]:
"""Enter context manager and return tools."""
return self.tools()
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit context manager."""

View File

@@ -2,12 +2,13 @@ import logging
from crewai.tools import BaseTool
from crewai_tools.adapters.tool_collection import ToolCollection
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
CrewaiPlatformToolBuilder,
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
)
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
ApplicationSelector,
IntegrationsClient,
LegacyClient,
)
@@ -16,19 +17,26 @@ logger = logging.getLogger(__name__)
def CrewaiPlatformTools( # noqa: N802
apps: list[str],
integrations_client: IntegrationsClient | None = None,
) -> ToolCollection[BaseTool]:
) -> list[BaseTool]:
"""Factory function that returns crewai platform tools.
Args:
apps: List of platform apps to get tools that are available on the platform.
integrations_client: Client used to get and execute actions.
Returns:
A list of BaseTool instances for platform actions
"""
builder = CrewaiPlatformToolBuilder(
apps=apps, integrations_client=integrations_client
)
selectors = [ApplicationSelector.from_string(app) for app in apps]
client: IntegrationsClient = LegacyClient()
return builder.tools() # type: ignore
try:
tool_infos = client.get_actions(selectors)
except ValueError:
raise
except Exception as error:
logger.error(f"Failed to fetch platform tools for apps {apps}: {error}")
return []
return [
CrewAIPlatformActionTool(tool_info, client=client) for tool_info in tool_infos
]

View File

@@ -1,70 +1,212 @@
"""Contract and default client for platform integrations."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from typing import Any, Protocol
from uuid import UUID
from crewai.utilities.string_utils import sanitize_tool_name
from crewai_core.plus_api import PlusAPI
import requests
from crewai_tools.tools.crewai_platform_tools.misc import (
get_platform_api_base_url,
get_platform_integration_token,
)
class IntegrationsResponse(Protocol):
"""Define the response operations used by platform tools."""
@dataclass(frozen=True)
class ApplicationSelector:
"""Represent an application selector."""
app: str
action: str | None
connection_id: UUID | None
@classmethod
def from_string(cls, value: str) -> ApplicationSelector:
"""Parse the ``application[/action][@connection_uuid]`` syntax.
Raises:
ValueError: If the selector does not follow the supported syntax.
"""
if not value:
raise ValueError(f"Invalid application selector {value!r}: cannot be empty")
if "@" in value and "/" in value and value.index("@") < value.index("/"):
raise ValueError(
f"Invalid application selector {value!r}: "
"connection ID must be the last segment"
)
app_and_action, connection_separator, connection_id = value.partition("@")
app, action_separator, action = app_and_action.partition("/")
if not app:
raise ValueError(
f"Invalid application selector {value!r}: application cannot be empty"
)
if action_separator and not action:
raise ValueError(
f"Invalid application selector {value!r}: action cannot be empty"
)
if connection_separator and not connection_id:
raise ValueError(
f"Invalid application selector {value!r}: connection ID cannot be empty"
)
parsed_connection_id = None
if connection_id:
try:
parsed_connection_id = UUID(connection_id)
except ValueError as error:
raise ValueError(
f"Invalid application selector {value!r}: "
"connection ID must be a valid UUID"
) from error
return cls(
app=app,
action=action if action_separator else None,
connection_id=parsed_connection_id,
)
@dataclass(frozen=True)
class ToolInfo:
"""Describe a normalized platform action."""
app: str
action: str
connection_id: UUID | None
description: str
parameters: dict[str, Any]
@property
def ok(self) -> bool:
"""Return whether the request succeeded."""
def qualified_name(self) -> str:
"""Return the qualified tool name."""
parts = [self.app, self.action]
if self.connection_id is not None:
parts.append(str(self.connection_id))
return sanitize_tool_name("_".join(parts))
@property
def status_code(self) -> int:
"""Return the HTTP response status code."""
def json(self) -> Any:
"""Decode the response body as JSON."""
@dataclass(frozen=True)
class ToolExecutionSuccess:
"""Represent a successful platform action execution."""
def raise_for_status(self) -> None:
"""Raise an error when the request failed."""
output: dict[str, Any]
@dataclass(frozen=True)
class ToolExecutionFailure:
"""Represent an expected platform action failure."""
message: str
code: str
retryable: bool
ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
class IntegrationsClient(Protocol):
"""Define the client operations required by CrewAI platform tools."""
"""Define the contract for platform integrations clients."""
def get_actions(self, apps: list[str]) -> IntegrationsResponse:
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
"""Get the actions available for the selected applications."""
def execute_action(
self, action_name: str, arguments: dict[str, Any]
) -> IntegrationsResponse:
self, tool: ToolInfo, arguments: dict[str, Any]
) -> ToolExecutionResult:
"""Execute an action with the given arguments."""
class LegacyClient:
"""Use the existing CrewAI platform integrations API."""
def get_actions(self, apps: list[str]) -> requests.Response:
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
"""Get the actions available for the selected applications."""
return requests.get(
f"{get_platform_api_base_url()}/actions",
plus_api = PlusAPI()
apps = [
f"{selector.app}/{selector.action}"
if selector.action is not None
else selector.app
for selector in selectors
]
response = requests.get(
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}/actions",
headers={"Authorization": f"Bearer {get_platform_integration_token()}"},
timeout=30,
params={"apps": ",".join(apps)},
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
)
response.raise_for_status()
tool_infos: list[ToolInfo] = []
action_categories = response.json().get("actions", {})
for app, actions in action_categories.items():
if not isinstance(actions, list):
continue
for action_data in actions:
if not isinstance(action_data, dict):
continue
if action := action_data.get("name"):
parameters = action_data.get("parameters", {})
if not isinstance(parameters, dict):
parameters = {}
tool_infos.extend(
ToolInfo(
app=app,
action=action,
connection_id=selector.connection_id,
description=action_data.get(
"description", f"Execute {action}"
),
parameters=parameters,
)
for selector in selectors
if selector.app == app and selector.action in (None, action)
)
return tool_infos
def execute_action(
self, action_name: str, arguments: dict[str, Any]
) -> requests.Response:
self, tool: ToolInfo, arguments: dict[str, Any]
) -> ToolExecutionResult:
"""Execute an action with the given arguments."""
return requests.post(
url=f"{get_platform_api_base_url()}/actions/{action_name}/execute",
plus_api = PlusAPI()
response = requests.post(
url=(
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}"
f"/actions/{tool.action}/execute"
),
headers={
"Authorization": f"Bearer {get_platform_integration_token()}",
"Content-Type": "application/json",
},
json={"integration": arguments if arguments else {"_noop": True}},
timeout=60,
allow_redirects=False,
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
)
data = response.json()
if not 200 <= response.status_code < 300:
if isinstance(data, dict):
error_info = data.get("error", {})
if isinstance(error_info, dict):
error_message = error_info.get("message", json.dumps(data))
else:
error_message = str(error_info)
else:
error_message = str(data)
return ToolExecutionFailure(
message=str(error_message),
code=str(response.status_code),
retryable=response.status_code >= 500,
)
return ToolExecutionSuccess(output=data)

View File

@@ -1,14 +1,8 @@
import os
def get_platform_api_base_url() -> str:
"""Get the platform API base URL from environment or use default."""
base_url = os.getenv("CREWAI_PLUS_URL", "https://app.crewai.com")
return f"{base_url}/crewai_plus/api/v1/integrations"
def get_platform_integration_token() -> str:
"""Get the platform API base URL from environment or use default."""
"""Get the platform integration token from the environment."""
token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or ""
if not token:
raise ValueError(

View File

@@ -1,43 +1,96 @@
from unittest.mock import patch, Mock
import os
from typing import cast
from unittest.mock import Mock, patch
from crewai.tools.tool_failure import ToolFailure
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
)
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
IntegrationsClient,
ToolExecutionFailure,
ToolExecutionSuccess,
ToolInfo,
)
class TestCrewAIPlatformActionToolVerify:
"""Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable"""
def setup_method(self):
self.action_schema = {
"function": {
"name": "test_action",
"parameters": {
"properties": {
"test_param": {
"type": "string",
"description": "Test parameter"
}
},
"required": []
}
}
}
def create_test_tool(self):
return CrewAIPlatformActionTool(
description="Test action tool",
self.tool_info = ToolInfo(
app="test_app",
action_name="test_action",
action_schema=self.action_schema
action="test_action",
connection_id=None,
description="Test action tool",
parameters={
"properties": {
"test_param": {
"type": "string",
"description": "Test parameter",
}
},
"required": [],
},
)
def create_test_tool(
self, client: IntegrationsClient | None = None
) -> CrewAIPlatformActionTool:
return CrewAIPlatformActionTool(self.tool_info, client=client)
def test_run_serializes_success_output(self):
client = Mock(spec=IntegrationsClient)
client.execute_action.return_value = ToolExecutionSuccess(
output={"result": {"id": 42}}
)
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
test_param="test_value", optional_param=None
)
assert result == '{\n "result": {\n "id": 42\n }\n}'
assert client.execute_action.call_args.args[1] == {"test_param": "test_value"}
def test_run_converts_expected_failure(self):
client = Mock(spec=IntegrationsClient)
client.execute_action.return_value = ToolExecutionFailure(
message="Channel not found",
code="404",
retryable=False,
)
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
test_param="test_value"
)
assert result == ToolFailure(
message="API request failed: Channel not found",
code="404",
retryable=False,
details={"action": "test_action"},
)
def test_run_preserves_unexpected_exception_fallback(self):
client = Mock(spec=IntegrationsClient)
client.execute_action.side_effect = ValueError("Invalid response JSON")
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
test_param="test_value"
)
assert result == ToolFailure(
message="Error executing action test_action: Invalid response JSON",
code="ValueError",
details={"action": "test_action"},
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
def test_run_with_ssl_verification_default(self, mock_post):
"""Test that _run uses SSL verification by default when CREWAI_FACTORY is not set"""
mock_response = Mock()
mock_response = Mock(status_code=200)
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
@@ -53,7 +106,7 @@ class TestCrewAIPlatformActionToolVerify:
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
def test_run_with_ssl_verification_factory_false(self, mock_post):
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'false'"""
mock_response = Mock()
mock_response = Mock(status_code=200)
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
@@ -69,7 +122,7 @@ class TestCrewAIPlatformActionToolVerify:
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
def test_run_with_ssl_verification_factory_false_uppercase(self, mock_post):
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
mock_response = Mock()
mock_response = Mock(status_code=200)
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
@@ -85,7 +138,7 @@ class TestCrewAIPlatformActionToolVerify:
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
def test_run_without_ssl_verification_factory_true(self, mock_post):
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'true'"""
mock_response = Mock()
mock_response = Mock(status_code=200)
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response
@@ -101,7 +154,7 @@ class TestCrewAIPlatformActionToolVerify:
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
def test_run_without_ssl_verification_factory_true_uppercase(self, mock_post):
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
mock_response = Mock()
mock_response = Mock(status_code=200)
mock_response.ok = True
mock_response.json.return_value = {"result": "success"}
mock_post.return_value = mock_response

View File

@@ -1,375 +0,0 @@
import unittest
from unittest.mock import Mock, patch
from crewai_tools.tools.crewai_platform_tools import (
CrewAIPlatformActionTool,
CrewaiPlatformToolBuilder,
)
import pytest
class TestCrewaiPlatformToolBuilder(unittest.TestCase):
@pytest.fixture
def platform_tool_builder(self):
"""Create a CrewaiPlatformToolBuilder instance for testing"""
return CrewaiPlatformToolBuilder(apps=["github", "slack"])
@pytest.fixture
def mock_api_response(self):
return {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue title",
},
"body": {"type": "string", "description": "Issue body"},
},
"required": ["title"],
},
}
],
"slack": [
{
"name": "send_message",
"description": "Send a Slack message",
"parameters": {
"type": "object",
"properties": {
"channel": {
"type": "string",
"description": "Channel name",
},
"text": {
"type": "string",
"description": "Message text",
},
},
"required": ["channel", "text"],
},
}
],
}
}
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_success(self, mock_get):
mock_api_response = {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue title",
}
},
"required": ["title"],
},
}
]
}
}
builder = CrewaiPlatformToolBuilder(
apps=["github", "slack/send_message", "custom/path/to/action"]
)
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = mock_api_response
mock_get.return_value = mock_response
builder._fetch_actions()
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
assert "/actions" in args[0]
assert kwargs["headers"]["Authorization"] == "Bearer test_token"
assert kwargs["params"]["apps"] == (
"github,slack/send_message,custom/path/to/action"
)
assert "create_issue" in builder._actions_schema
assert (
builder._actions_schema["create_issue"]["function"]["name"]
== "create_issue"
)
def test_fetch_actions_no_token(self):
builder = CrewaiPlatformToolBuilder(apps=["github"])
with patch.dict("os.environ", {}, clear=True):
with self.assertRaises(ValueError) as context:
builder._fetch_actions()
assert "No platform integration token found" in str(context.exception)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_create_tools(self, mock_get):
mock_api_response = {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue title",
}
},
"required": ["title"],
},
}
],
"slack": [
{
"name": "send_message",
"description": "Send a Slack message",
"parameters": {
"type": "object",
"properties": {
"channel": {
"type": "string",
"description": "Channel name",
}
},
"required": ["channel"],
},
}
],
}
}
builder = CrewaiPlatformToolBuilder(apps=["github", "slack"])
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = mock_api_response
mock_get.return_value = mock_response
tools = builder.tools()
assert len(tools) == 2
assert all(isinstance(tool, CrewAIPlatformActionTool) for tool in tools)
tool_names = [tool.action_name for tool in tools]
assert "create_issue" in tool_names
assert "send_message" in tool_names
assert {tool.action_name: tool.app for tool in tools} == {
"create_issue": "github",
"send_message": "slack",
}
github_tool = next((t for t in tools if t.action_name == "create_issue"), None)
slack_tool = next((t for t in tools if t.action_name == "send_message"), None)
assert github_tool is not None
assert slack_tool is not None
assert "Create a GitHub issue" in github_tool.description
assert "Send a Slack message" in slack_tool.description
def test_tools_caching(self):
builder = CrewaiPlatformToolBuilder(apps=["github"])
cached_tools = []
def mock_create_tools():
builder._tools = cached_tools
with (
patch.object(builder, "_fetch_actions") as mock_fetch,
patch.object(
builder, "_create_tools", side_effect=mock_create_tools
) as mock_create,
):
tools1 = builder.tools()
assert mock_fetch.call_count == 1
assert mock_create.call_count == 1
tools2 = builder.tools()
assert mock_fetch.call_count == 1
assert mock_create.call_count == 1
assert tools1 is tools2
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
def test_empty_apps_list(self):
builder = CrewaiPlatformToolBuilder(apps=[])
with patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
) as mock_get:
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
tools = builder.tools()
assert isinstance(tools, list)
assert len(tools) == 0
_, kwargs = mock_get.call_args
assert kwargs["params"]["apps"] == ""
class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase):
"""Test suite for SSL verification behavior in CrewaiPlatformToolBuilder"""
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_with_ssl_verification_default(self, mock_get):
"""Test that _fetch_actions uses SSL verification by default when CREWAI_FACTORY is not set"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_with_ssl_verification_factory_false(self, mock_get):
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'false'"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_with_ssl_verification_factory_false_uppercase(self, mock_get):
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is True
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_without_ssl_verification_factory_true(self, mock_get):
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'true'"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is False
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_fetch_actions_without_ssl_verification_factory_true_uppercase(self, mock_get):
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(apps=["github"])
builder._fetch_actions()
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args.kwargs["verify"] is False
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_connection_ids_are_parsed_but_not_sent(mock_get):
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"actions": {}}
mock_get.return_value = mock_response
builder = CrewaiPlatformToolBuilder(
apps=[
"github@550E8400-E29B-41D4-A716-446655440000",
"slack/send_message@67e55044-10b1-426f-9247-bb680e5fe0c8",
]
)
builder.tools()
assert mock_get.call_args.kwargs["params"]["apps"] == (
"github,slack/send_message"
)
@pytest.mark.parametrize(
("selector", "message"),
[
("", "cannot be empty"),
(
"@550e8400-e29b-41d4-a716-446655440000",
"application cannot be empty",
),
("github/", "action cannot be empty"),
("github@", "connection ID cannot be empty"),
("github@not-a-uuid", "connection ID must be a valid UUID"),
(
"github@550e8400-e29b-41d4-a716-446655440000/issues",
"connection ID must be the last segment",
),
],
)
def test_rejects_invalid_app_selector(selector, message):
with pytest.raises(ValueError) as error:
CrewaiPlatformToolBuilder(apps=[selector])
assert repr(selector) in str(error.value)
assert message in str(error.value)

View File

@@ -17,7 +17,7 @@ class TestCrewaiPlatformTools(unittest.TestCase):
tools = CrewaiPlatformTools(apps=["github"])
assert tools is not None
assert isinstance(tools, list)
assert type(tools) is list
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
@@ -73,6 +73,13 @@ class TestCrewaiPlatformTools(unittest.TestCase):
assert tools is not None
assert isinstance(tools, list)
assert len(tools) == 2
assert [tool.name for tool in tools] == [
"github_create_issue",
"slack_send_message",
]
assert [tool.app for tool in tools] == ["github", "slack"]
assert tools[0].description == "Create a GitHub issue"
assert tools[1].description == "Send a Slack message"
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
@@ -81,6 +88,45 @@ class TestCrewaiPlatformTools(unittest.TestCase):
or kwargs.get("params", {}).get("apps") == "github,slack"
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_invalid_parameter_schemas_do_not_abort_discovery(self, mock_get):
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": "invalid",
},
{
"name": "close_issue",
"description": "Close a GitHub issue",
"parameters": [{"type": "string"}],
},
{
"name": "list_issues",
"description": "List GitHub issues",
"parameters": {},
},
]
}
}
mock_get.return_value = mock_response
tools = CrewaiPlatformTools(apps=["github"])
assert [tool.name for tool in tools] == [
"github_create_issue",
"github_close_issue",
"github_list_issues",
]
assert all(tool.args_schema.model_fields == {} for tool in tools)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
def test_crewai_platform_tools_empty_apps(self):
with patch(
@@ -114,11 +160,17 @@ class TestCrewaiPlatformTools(unittest.TestCase):
CrewaiPlatformTools(apps=["github"])
assert "No platform integration token found" in str(context.exception)
def test_crewai_platform_tools_accepts_an_integrations_client(self):
integrations_client = Mock()
actions_response = Mock()
actions_response.raise_for_status.return_value = None
actions_response.json.return_value = {
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
)
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_discovered_tool_executes_through_legacy_api(self, mock_get, mock_post):
discovery_response = Mock()
discovery_response.raise_for_status.return_value = None
discovery_response.json.return_value = {
"actions": {
"github": [
{
@@ -133,18 +185,85 @@ class TestCrewaiPlatformTools(unittest.TestCase):
]
}
}
integrations_client.get_actions.return_value = actions_response
mock_get.return_value = discovery_response
execution_response = Mock(ok=True, status_code=200)
execution_response.json.return_value = {"issue": 42}
integrations_client.execute_action.return_value = execution_response
mock_post.return_value = execution_response
tools = CrewaiPlatformTools(
apps=["github"], integrations_client=integrations_client
apps=["github@550e8400-e29b-41d4-a716-446655440000"]
)
result = tools[0].run(title="Contract test")
integrations_client.get_actions.assert_called_once_with(["github"])
integrations_client.execute_action.assert_called_once_with(
"create_issue", {"title": "Contract test"}
assert mock_get.call_args.kwargs["params"] == {"apps": "github"}
assert mock_post.call_args.kwargs["url"].endswith(
"/actions/create_issue/execute"
)
assert mock_post.call_args.kwargs["json"] == {
"integration": {"title": "Contract test"}
}
assert result == '{\n "issue": 42\n}'
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_same_action_from_different_apps_has_unique_tool_names(self, mock_get):
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"actions": {
"github": [
{
"name": "search",
"description": "Search GitHub",
"parameters": {},
}
],
"slack": [
{
"name": "search",
"description": "Search Slack",
"parameters": {},
}
],
}
}
mock_get.return_value = response
tools = CrewaiPlatformTools(apps=["github", "slack"])
assert len(tools) == 2
assert [tool.name for tool in tools] == ["github_search", "slack_search"]
assert [tool.app for tool in tools] == ["github", "slack"]
assert [tool.description for tool in tools] == ["Search GitHub", "Search Slack"]
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_tool_name_uses_its_sanitized_identity(self, mock_get):
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"actions": {
"Google Drive": [
{
"name": "CreateFile!",
"description": "Create a file",
"parameters": {},
}
]
}
}
mock_get.return_value = response
tools = CrewaiPlatformTools(
apps=[
"Google Drive/CreateFile!@550e8400-e29b-41d4-a716-446655440000"
]
)
assert tools[0].name == (
"google_drive_create_file_550e8400_e29b_41d4_a716_446655440000"
)

View File

@@ -0,0 +1,347 @@
from dataclasses import FrozenInstanceError
from typing import Any
from unittest.mock import Mock, patch
from uuid import UUID
import pytest
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
ApplicationSelector,
IntegrationsClient,
LegacyClient,
ToolExecutionFailure,
ToolExecutionSuccess,
ToolInfo,
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_legacy_client_normalizes_discovered_actions(mock_get: Mock) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": {
"type": "object",
"properties": {"title": {"type": "string"}},
},
}
]
}
}
mock_get.return_value = response
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
tools = LegacyClient().get_actions(
[ApplicationSelector.from_string(f"github/create_issue@{connection_id}")]
)
assert tools == [
ToolInfo(
app="github",
action="create_issue",
connection_id=connection_id,
description="Create a GitHub issue",
parameters={
"type": "object",
"properties": {"title": {"type": "string"}},
},
)
]
response.raise_for_status.assert_called_once_with()
assert mock_get.call_args.kwargs["params"] == {"apps": "github/create_issue"}
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_legacy_client_emits_action_for_each_matching_selector(
mock_get: Mock,
) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"actions": {
"github": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"parameters": {},
}
]
}
}
mock_get.return_value = response
app_connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
action_connection_id = UUID("8c5f9d69-902b-4b48-a23c-8d037c242e1e")
tools = LegacyClient().get_actions(
[
ApplicationSelector.from_string(f"github@{app_connection_id}"),
ApplicationSelector.from_string(
f"github/create_issue@{action_connection_id}"
),
]
)
assert [tool.connection_id for tool in tools] == [
app_connection_id,
action_connection_id,
]
assert [tool.qualified_name for tool in tools] == [
"github_create_issue_550e8400_e29b_41d4_a716_446655440000",
"github_create_issue_8c5f9d69_902b_4b48_a23c_8d037c242e1e",
]
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_legacy_client_excludes_actions_without_a_matching_selector(
mock_get: Mock,
) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"actions": {
"github": [
{
"name": "delete_issue",
"description": "Delete a GitHub issue",
"parameters": {},
}
]
}
}
mock_get.return_value = response
tools = LegacyClient().get_actions(
[ApplicationSelector.from_string("github/create_issue")]
)
assert tools == []
def test_tool_info_is_immutable() -> None:
tool_info = ToolInfo(
app="github",
action="create_issue",
connection_id=None,
description="Create an issue",
parameters={},
)
with pytest.raises(FrozenInstanceError):
tool_info.action = "delete_issue"
@pytest.mark.parametrize(
("result", "field", "value"),
[
(ToolExecutionSuccess(output={"issue": 42}), "output", {"issue": 43}),
(
ToolExecutionFailure(
message="Request failed", code="400", retryable=False
),
"message",
"Another failure",
),
],
)
def test_tool_execution_results_are_immutable(
result: ToolExecutionSuccess | ToolExecutionFailure,
field: str,
value: Any,
) -> None:
with pytest.raises(FrozenInstanceError):
setattr(result, field, value)
def test_application_selector_is_immutable() -> None:
selector = ApplicationSelector.from_string(
"github/create_issue@550e8400-e29b-41d4-a716-446655440000"
)
assert selector.app == "github"
assert selector.action == "create_issue"
assert selector.connection_id == UUID("550e8400-e29b-41d4-a716-446655440000")
with pytest.raises(FrozenInstanceError):
selector.action = "delete_issue"
@pytest.mark.parametrize(
("value", "message"),
[
("", "cannot be empty"),
(
"@550e8400-e29b-41d4-a716-446655440000",
"application cannot be empty",
),
("github/", "action cannot be empty"),
("github@", "connection ID cannot be empty"),
("github@not-a-uuid", "connection ID must be a valid UUID"),
(
"github@550e8400-e29b-41d4-a716-446655440000/issues",
"connection ID must be the last segment",
),
],
)
def test_application_selector_rejects_invalid_values(
value: str, message: str
) -> None:
with pytest.raises(ValueError) as error:
ApplicationSelector.from_string(value)
assert repr(value) in str(error.value)
assert message in str(error.value)
@pytest.mark.parametrize(
("factory_value", "verify"),
[
(None, True),
("false", True),
("FALSE", True),
("true", False),
("TRUE", False),
],
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
)
def test_legacy_client_preserves_discovery_ssl_behavior(
mock_get: Mock,
factory_value: str | None,
verify: bool,
monkeypatch: pytest.MonkeyPatch,
) -> None:
if factory_value is None:
monkeypatch.delenv("CREWAI_FACTORY", raising=False)
else:
monkeypatch.setenv("CREWAI_FACTORY", factory_value)
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"actions": {}}
mock_get.return_value = response
LegacyClient().get_actions([ApplicationSelector.from_string("github")])
assert mock_get.call_args.kwargs["verify"] is verify
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
)
@pytest.mark.parametrize(
("arguments", "integration"),
[({"title": "Contract test"}, {"title": "Contract test"}), ({}, {"_noop": True})],
)
def test_legacy_client_preserves_execution_request(
mock_post: Mock,
arguments: dict[str, Any],
integration: dict[str, Any],
) -> None:
response = Mock(status_code=200)
response.json.return_value = {"issue": 42}
mock_post.return_value = response
tool_info = ToolInfo(
app="github",
action="create_issue",
connection_id=UUID("550e8400-e29b-41d4-a716-446655440000"),
description="Create an issue",
parameters={},
)
client: IntegrationsClient = LegacyClient()
result = client.execute_action(tool_info, arguments)
assert result == ToolExecutionSuccess(output={"issue": 42})
mock_post.assert_called_once()
assert mock_post.call_args.kwargs["url"].endswith(
"/actions/create_issue/execute"
)
assert mock_post.call_args.kwargs["headers"] == {
"Authorization": "Bearer test_token",
"Content-Type": "application/json",
}
assert mock_post.call_args.kwargs["json"] == {"integration": integration}
assert mock_post.call_args.kwargs["timeout"] == 60
assert mock_post.call_args.kwargs["allow_redirects"] is False
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
)
@pytest.mark.parametrize(
("response_data", "status_code", "message", "retryable"),
[
({"error": {"message": "Invalid issue"}}, 400, "Invalid issue", False),
({"error": "Rate limited"}, 429, "Rate limited", False),
(["Service unavailable"], 503, "['Service unavailable']", True),
({"reason": "Unknown"}, 500, '{"reason": "Unknown"}', True),
],
)
def test_legacy_client_normalizes_execution_failures(
mock_post: Mock,
response_data: Any,
status_code: int,
message: str,
retryable: bool,
) -> None:
response = Mock(status_code=status_code)
response.json.return_value = response_data
mock_post.return_value = response
tool_info = ToolInfo(
app="github",
action="create_issue",
connection_id=None,
description="Create an issue",
parameters={},
)
result = LegacyClient().execute_action(tool_info, {"title": "Contract test"})
assert result == ToolExecutionFailure(
message=message,
code=str(status_code),
retryable=retryable,
)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
)
def test_legacy_client_treats_redirect_as_execution_failure(
mock_post: Mock,
) -> None:
response = Mock(status_code=302)
response.json.return_value = {"error": {"message": "Redirected"}}
mock_post.return_value = response
tool_info = ToolInfo(
app="github",
action="create_issue",
connection_id=None,
description="Create an issue",
parameters={},
)
result = LegacyClient().execute_action(tool_info, {})
assert result == ToolExecutionFailure(
message="Redirected",
code="302",
retryable=False,
)

View File

@@ -1693,51 +1693,55 @@ class TestPlatformActionTool:
"""CrewAI AMP agentic-app actions -- the Slack case from the bug report."""
@staticmethod
def _tool(response: Any) -> Any:
def _tool() -> Any:
import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
return mod.CrewAIPlatformActionTool(
description="Send a Slack message",
app="slack",
action_name="slackbot_send_message",
action_schema={
"function": {
"name": "slackbot_send_message",
"parameters": {
"properties": {"channel": {"type": "string"}},
"required": [],
},
}
},
integrations_client=SimpleNamespace(
execute_action=lambda _action_name, _arguments: response
),
client_mod.ToolInfo(
app="slack",
action="slackbot_send_message",
connection_id=None,
description="Send a Slack message",
parameters={
"properties": {"channel": {"type": "string"}},
"required": [],
},
)
)
def test_non_ok_response_becomes_a_tool_failure(self) -> None:
def test_non_ok_response_becomes_a_tool_failure(self, monkeypatch) -> None: # noqa: ANN001
from unittest.mock import Mock
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
response = Mock()
response.ok = False
response.status_code = 500
response.json.return_value = {
"error": "Failed to execute action: Slack API error: channel_not_found"
}
monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response))
monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t")
result = self._tool(response)._run(channel="#joao-message")
result = self._tool()._run(channel="#joao-message")
assert isinstance(result, ToolFailure)
assert "channel_not_found" in result.message
assert result.retryable is True
def test_ok_response_still_returns_json(self) -> None:
def test_ok_response_still_returns_json(self, monkeypatch) -> None: # noqa: ANN001
from unittest.mock import Mock
response = Mock()
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
response = Mock(status_code=200)
response.ok = True
response.json.return_value = {"ts": "1234.5678"}
monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response))
monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t")
result = self._tool(response)._run(channel="#general")
result = self._tool()._run(channel="#general")
assert not isinstance(result, ToolFailure)
assert "1234.5678" in result