mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-22 01:43:13 +02:00
* Mark the platform action picker as searchable. The flag is off for every other multi-select, and filtering comes in the next change. * Filter picker labels by query while keeping their original indexes. An empty query matches every label, and a typed query can match the display name or the slug. * Let the platform action picker filter as the user types. The arrow-key list shows the query and hides non-matching rows, and the numbered fallback uses the same filter without dropping checked actions. * Erase leftover rows when a search result is shorter than the previous list. A filtered action picker now shows only the matching actions. * Show the action search in green under the navigation hint. The line includes how many actions the query matched.
1443 lines
51 KiB
Python
1443 lines
51 KiB
Python
import asyncio
|
|
import builtins
|
|
import keyword
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import threading
|
|
import warnings
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import click
|
|
import pytest
|
|
import tomli
|
|
from click.testing import CliRunner
|
|
from crewai_core.platform_apps import PLATFORM_APPS
|
|
from packaging.requirements import Requirement
|
|
from packaging.version import Version
|
|
import crewai_cli.create_json_crew as json_crew
|
|
import crewai_cli.tui_picker as tui_picker
|
|
from crewai_cli.cli import crewai
|
|
from crewai_cli.create_crew import create_crew, create_folder_structure
|
|
from crewai_cli.utils import copy_assistant_instructions, render_template
|
|
from crewai_cli.version import get_crewai_tools_dependency
|
|
|
|
|
|
@pytest.fixture
|
|
def runner():
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
temp_path = tempfile.mkdtemp()
|
|
yield temp_path
|
|
shutil.rmtree(temp_path)
|
|
|
|
|
|
def test_create_folder_structure_strips_single_trailing_slash():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"hello/", parent_folder=temp_dir
|
|
)
|
|
|
|
assert folder_name == "hello"
|
|
assert class_name == "Hello"
|
|
assert folder_path.name == "hello"
|
|
assert folder_path.exists()
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_strips_multiple_trailing_slashes():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"hello///", parent_folder=temp_dir
|
|
)
|
|
|
|
assert folder_name == "hello"
|
|
assert class_name == "Hello"
|
|
assert folder_path.name == "hello"
|
|
assert folder_path.exists()
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_handles_complex_name_with_trailing_slash():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"my-awesome_project/", parent_folder=temp_dir
|
|
)
|
|
|
|
assert folder_name == "my_awesome_project"
|
|
assert class_name == "MyAwesomeProject"
|
|
assert folder_path.name == "my_awesome_project"
|
|
assert folder_path.exists()
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_normal_name_unchanged():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"hello", parent_folder=temp_dir
|
|
)
|
|
|
|
assert folder_name == "hello"
|
|
assert class_name == "Hello"
|
|
assert folder_path.name == "hello"
|
|
assert folder_path.exists()
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_with_parent_folder():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
parent_path = Path(temp_dir) / "parent"
|
|
parent_path.mkdir()
|
|
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"child/", parent_folder=parent_path
|
|
)
|
|
|
|
assert folder_name == "child"
|
|
assert class_name == "Child"
|
|
assert folder_path.name == "child"
|
|
assert folder_path.parent == parent_path
|
|
assert folder_path.exists()
|
|
|
|
|
|
@mock.patch("crewai_cli.create_crew.copy_template")
|
|
@mock.patch("crewai_cli.create_crew.write_env_file")
|
|
@mock.patch("crewai_cli.create_crew.load_env_vars")
|
|
def test_create_crew_with_trailing_slash_creates_valid_project(
|
|
mock_load_env, mock_write_env, mock_copy_template, temp_dir
|
|
):
|
|
mock_load_env.return_value = {}
|
|
|
|
with tempfile.TemporaryDirectory() as work_dir:
|
|
with mock.patch(
|
|
"crewai_cli.create_crew.create_folder_structure"
|
|
) as mock_create_folder:
|
|
mock_folder_path = Path(work_dir) / "test_project"
|
|
mock_folder_path.mkdir()
|
|
mock_create_folder.return_value = (
|
|
mock_folder_path,
|
|
"test_project",
|
|
"TestProject",
|
|
)
|
|
|
|
create_crew("test-project/", skip_provider=True)
|
|
|
|
mock_create_folder.assert_called_once_with("test-project/", None)
|
|
mock_copy_template.assert_called()
|
|
copy_calls = mock_copy_template.call_args_list
|
|
|
|
for call in copy_calls:
|
|
args = call[0]
|
|
if len(args) >= 5:
|
|
folder_name_arg = args[4]
|
|
assert not folder_name_arg.endswith("/"), (
|
|
f"folder_name should not end with slash: {folder_name_arg}"
|
|
)
|
|
|
|
|
|
@mock.patch("crewai_cli.create_crew.copy_template")
|
|
@mock.patch("crewai_cli.create_crew.write_env_file")
|
|
@mock.patch("crewai_cli.create_crew.load_env_vars")
|
|
def test_create_crew_with_multiple_trailing_slashes(
|
|
mock_load_env, mock_write_env, mock_copy_template, temp_dir
|
|
):
|
|
mock_load_env.return_value = {}
|
|
|
|
with tempfile.TemporaryDirectory() as work_dir:
|
|
with mock.patch(
|
|
"crewai_cli.create_crew.create_folder_structure"
|
|
) as mock_create_folder:
|
|
mock_folder_path = Path(work_dir) / "test_project"
|
|
mock_folder_path.mkdir()
|
|
mock_create_folder.return_value = (
|
|
mock_folder_path,
|
|
"test_project",
|
|
"TestProject",
|
|
)
|
|
|
|
create_crew("test-project///", skip_provider=True)
|
|
|
|
mock_create_folder.assert_called_once_with("test-project///", None)
|
|
|
|
|
|
@mock.patch("crewai_cli.create_crew.copy_template")
|
|
@mock.patch("crewai_cli.create_crew.write_env_file")
|
|
@mock.patch("crewai_cli.create_crew.load_env_vars")
|
|
def test_create_crew_normal_name_still_works(
|
|
mock_load_env, mock_write_env, mock_copy_template, temp_dir
|
|
):
|
|
mock_load_env.return_value = {}
|
|
|
|
with tempfile.TemporaryDirectory() as work_dir:
|
|
with mock.patch(
|
|
"crewai_cli.create_crew.create_folder_structure"
|
|
) as mock_create_folder:
|
|
mock_folder_path = Path(work_dir) / "normal_project"
|
|
mock_folder_path.mkdir()
|
|
mock_create_folder.return_value = (
|
|
mock_folder_path,
|
|
"normal_project",
|
|
"NormalProject",
|
|
)
|
|
|
|
create_crew("normal-project", skip_provider=True)
|
|
|
|
mock_create_folder.assert_called_once_with("normal-project", None)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed")
|
|
@pytest.mark.parametrize(
|
|
("args", "project_root"),
|
|
[
|
|
(["create", "crew", "Git Crew"], "git_crew"),
|
|
(["create", "flow", "Git Flow"], "git_flow"),
|
|
],
|
|
)
|
|
def test_create_initializes_git_repo_when_git_is_available(
|
|
args, project_root, tmp_path, monkeypatch, runner
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("CREWAI_DMN", "True")
|
|
|
|
result = runner.invoke(crewai, args)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert (tmp_path / project_root / ".git").is_dir()
|
|
|
|
|
|
def test_create_folder_structure_handles_spaces_and_dashes_with_slash():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
"My Cool-Project/", parent_folder=temp_dir
|
|
)
|
|
|
|
assert folder_name == "my_cool_project"
|
|
assert class_name == "MyCoolProject"
|
|
assert folder_path.name == "my_cool_project"
|
|
assert folder_path.exists()
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_raises_error_for_invalid_names():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
invalid_cases = [
|
|
("123project/", "cannot start with a digit"),
|
|
("True/", "reserved Python keyword"),
|
|
("False/", "reserved Python keyword"),
|
|
("None/", "reserved Python keyword"),
|
|
("class/", "reserved Python keyword"),
|
|
("def/", "reserved Python keyword"),
|
|
(" /", "empty or contain only whitespace"),
|
|
("", "empty or contain only whitespace"),
|
|
("@#$/", "contains no valid characters"),
|
|
]
|
|
|
|
for invalid_name, expected_error in invalid_cases:
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
create_folder_structure(invalid_name, parent_folder=temp_dir)
|
|
|
|
|
|
def test_create_folder_structure_validates_names():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
valid_cases = [
|
|
("hello/", "hello", "Hello"),
|
|
("my-project/", "my_project", "MyProject"),
|
|
("hello_world/", "hello_world", "HelloWorld"),
|
|
("valid123/", "valid123", "Valid123"),
|
|
("hello.world/", "helloworld", "HelloWorld"),
|
|
("hello@world/", "helloworld", "HelloWorld"),
|
|
]
|
|
|
|
for valid_name, expected_folder, expected_class in valid_cases:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
valid_name, parent_folder=temp_dir
|
|
)
|
|
assert folder_name == expected_folder
|
|
assert class_name == expected_class
|
|
|
|
assert folder_name.isidentifier(), (
|
|
f"folder_name '{folder_name}' should be valid Python identifier"
|
|
)
|
|
assert not keyword.iskeyword(folder_name), (
|
|
f"folder_name '{folder_name}' should not be Python keyword"
|
|
)
|
|
assert not folder_name[0].isdigit(), (
|
|
f"folder_name '{folder_name}' should not start with digit"
|
|
)
|
|
|
|
assert class_name.isidentifier(), (
|
|
f"class_name '{class_name}' should be valid Python identifier"
|
|
)
|
|
assert not keyword.iskeyword(class_name), (
|
|
f"class_name '{class_name}' should not be Python keyword"
|
|
)
|
|
assert folder_path.parent == Path(temp_dir)
|
|
|
|
if folder_path.exists():
|
|
shutil.rmtree(folder_path)
|
|
|
|
|
|
@mock.patch("crewai_cli.create_crew.copy_template")
|
|
@mock.patch("crewai_cli.create_crew.write_env_file")
|
|
@mock.patch("crewai_cli.create_crew.load_env_vars")
|
|
def test_create_crew_with_parent_folder_and_trailing_slash(
|
|
mock_load_env, mock_write_env, mock_copy_template, temp_dir
|
|
):
|
|
mock_load_env.return_value = {}
|
|
|
|
with tempfile.TemporaryDirectory() as work_dir:
|
|
parent_path = Path(work_dir) / "parent"
|
|
parent_path.mkdir()
|
|
|
|
create_crew("child-crew/", skip_provider=True, parent_folder=parent_path)
|
|
|
|
crew_path = parent_path / "child_crew"
|
|
assert crew_path.exists()
|
|
assert not (crew_path / "src").exists()
|
|
|
|
|
|
def test_create_folder_structure_folder_name_validation():
|
|
"""Test that folder names are validated as valid Python module names"""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
folder_invalid_cases = [
|
|
("123invalid/", "cannot start with a digit.*invalid Python module name"),
|
|
("import/", "reserved Python keyword"),
|
|
("class/", "reserved Python keyword"),
|
|
("for/", "reserved Python keyword"),
|
|
("@#$invalid/", "contains no valid characters.*Python module name"),
|
|
]
|
|
|
|
for invalid_name, expected_error in folder_invalid_cases:
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
create_folder_structure(invalid_name, parent_folder=temp_dir)
|
|
|
|
valid_cases = [
|
|
("hello-world/", "hello_world"),
|
|
("my.project/", "myproject"),
|
|
("test@123/", "test123"),
|
|
("valid_name/", "valid_name"),
|
|
]
|
|
|
|
for valid_name, expected_folder in valid_cases:
|
|
folder_path, folder_name, class_name = create_folder_structure(
|
|
valid_name, parent_folder=temp_dir
|
|
)
|
|
assert folder_name == expected_folder
|
|
assert folder_name.isidentifier()
|
|
assert not keyword.iskeyword(folder_name)
|
|
|
|
if folder_path.exists():
|
|
shutil.rmtree(folder_path)
|
|
|
|
|
|
def test_create_folder_structure_rejects_reserved_names():
|
|
"""Test that reserved script names are rejected to prevent pyproject.toml conflicts."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
reserved_names = ["test", "train", "replay", "run_crew", "run_with_trigger"]
|
|
|
|
for reserved_name in reserved_names:
|
|
with pytest.raises(ValueError, match="which is reserved"):
|
|
create_folder_structure(reserved_name, parent_folder=temp_dir)
|
|
|
|
with pytest.raises(ValueError, match="which is reserved"):
|
|
create_folder_structure(f"{reserved_name}/", parent_folder=temp_dir)
|
|
|
|
capitalized = reserved_name.capitalize()
|
|
with pytest.raises(ValueError, match="which is reserved"):
|
|
create_folder_structure(capitalized, parent_folder=temp_dir)
|
|
|
|
|
|
@mock.patch("crewai_cli.create_crew.create_folder_structure")
|
|
@mock.patch("crewai_cli.create_crew.copy_template")
|
|
@mock.patch("crewai_cli.create_crew.load_env_vars")
|
|
@mock.patch("crewai_cli.create_crew.get_provider_data")
|
|
@mock.patch("crewai_cli.create_crew.select_provider")
|
|
@mock.patch("crewai_cli.create_crew.select_model")
|
|
@mock.patch("click.prompt")
|
|
def test_env_vars_are_uppercased_in_env_file(
|
|
mock_prompt,
|
|
mock_select_model,
|
|
mock_select_provider,
|
|
mock_get_provider_data,
|
|
mock_load_env_vars,
|
|
mock_copy_template,
|
|
mock_create_folder_structure,
|
|
tmp_path,
|
|
):
|
|
crew_path = tmp_path / "test_crew"
|
|
crew_path.mkdir()
|
|
mock_create_folder_structure.return_value = (crew_path, "test_crew", "TestCrew")
|
|
|
|
mock_load_env_vars.return_value = {}
|
|
mock_get_provider_data.return_value = {"openai": ["gpt-4"]}
|
|
mock_select_provider.return_value = "azure"
|
|
mock_select_model.return_value = "azure/openai"
|
|
mock_prompt.return_value = "fake-api-key"
|
|
|
|
create_crew("Test Crew")
|
|
|
|
env_file_path = crew_path / ".env"
|
|
content = env_file_path.read_text()
|
|
assert "MODEL=" in content
|
|
|
|
|
|
def test_json_wizard_defaults_to_sequential_and_memory_enabled(monkeypatch):
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"_wizard_agent",
|
|
lambda **_: {
|
|
"name": "researcher",
|
|
"role": "Researcher",
|
|
"goal": "Research",
|
|
"backstory": "Researcher",
|
|
"llm": "openai/gpt-5.5",
|
|
"tools": [],
|
|
"planning": False,
|
|
"allow_delegation": False,
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"_wizard_task",
|
|
lambda **_: {
|
|
"name": "research_task",
|
|
"description": "Research",
|
|
"expected_output": "Findings",
|
|
"agent": "researcher",
|
|
"context": [],
|
|
},
|
|
)
|
|
|
|
def confirm(label: str, default: bool = False) -> bool:
|
|
if label == "Enable crew memory?":
|
|
return default
|
|
return False
|
|
|
|
monkeypatch.setattr(json_crew, "_confirm", confirm)
|
|
monkeypatch.setattr(json_crew.click, "prompt", lambda *_, **__: "")
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"pick_one",
|
|
lambda *_args, **_kwargs: pytest.fail("process should not be prompted"),
|
|
)
|
|
|
|
_agents, _tasks, settings = json_crew._wizard_agents_and_tasks(
|
|
skip_provider=True,
|
|
default_llm="openai/gpt-5.5",
|
|
)
|
|
|
|
assert settings == {"process": "sequential", "memory": True, "inputs": {}}
|
|
|
|
|
|
def test_json_wizard_shows_interpolation_hint(capsys):
|
|
json_crew._show_interpolation_hint("tasks")
|
|
|
|
output = capsys.readouterr().out
|
|
assert "{placeholder}" in output
|
|
assert "dynamic values" in output
|
|
assert "{topic}" not in output
|
|
assert "Description >" not in output
|
|
assert '"description"' not in output
|
|
|
|
|
|
def test_json_wizard_text_prompt_uses_full_prompt_for_readline(monkeypatch):
|
|
prompts: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
json_crew, "_readline_safe_prompt", lambda prompt: f"safe:{prompt}"
|
|
)
|
|
monkeypatch.setattr(
|
|
"builtins.input", lambda prompt: prompts.append(prompt) or "Draft content"
|
|
)
|
|
|
|
assert json_crew._prompt_text("Goal", spacing_before=False) == "Draft content"
|
|
assert len(prompts) == 1
|
|
assert prompts[0].startswith("safe:")
|
|
assert "Goal" in prompts[0]
|
|
assert " > " in prompts[0]
|
|
|
|
|
|
def test_json_wizard_tool_picker_prioritizes_common_tools(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
return [1, 3], None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
tools = json_crew._select_tools()
|
|
|
|
assert tools == ["SerperDevTool", "FileReadTool"]
|
|
assert len(picker_calls) == 1
|
|
labels = picker_calls[0][1]
|
|
assert 0 in picker_calls[0][2]["separator_indices"]
|
|
assert labels[0] == "── Common tools ──"
|
|
assert labels[1].strip().endswith("SerperDevTool")
|
|
assert labels[2].strip().endswith("ScrapeWebsiteTool")
|
|
assert labels[3].strip().endswith("FileReadTool")
|
|
assert labels[4].startswith("Gmail Integration")
|
|
assert labels[4].endswith("Platform: GmailIntegration (66 tools)")
|
|
assert labels[5].startswith("WhatsApp Integration")
|
|
assert labels[5].endswith("Platform: WhatsAppIntegration (58 tools)")
|
|
assert labels[1].index("Google search") < labels[1].index("SerperDevTool")
|
|
assert "More tools" not in labels
|
|
|
|
|
|
def test_platform_action_picker_is_searchable(monkeypatch):
|
|
picker_calls: list[dict[str, object]] = []
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append(kwargs)
|
|
return []
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
assert json_crew._select_platform_actions(["platform:github"]) == []
|
|
assert picker_calls[0]["searchable"] is True
|
|
|
|
|
|
def test_json_wizard_tool_picker_collapses_categories_by_default(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
return [], None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
json_crew._select_tools()
|
|
|
|
labels = picker_calls[0][1]
|
|
action_indices = picker_calls[0][2]["action_indices"]
|
|
# Categories show as collapsed action rows, not separators with tools
|
|
assert any(label.startswith("▸ Search & Research") for label in labels)
|
|
assert any(label.startswith("▸ Web Scraping") for label in labels)
|
|
assert not any(label.strip().endswith("BraveSearchTool") for label in labels)
|
|
assert len(action_indices) >= 4
|
|
# Only the common tools section is visible beyond the category rows
|
|
assert len(labels) == 1 + 5 + len(action_indices)
|
|
assert any(label == "▸ CrewAI Platform (125 applications)" for label in labels)
|
|
|
|
|
|
def test_json_wizard_tool_picker_expands_one_category_at_a_time(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
|
|
def find_category_row(labels: list[str], category: str) -> int:
|
|
return next(
|
|
idx for idx, label in enumerate(labels) if category in label
|
|
)
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
call_num = len(picker_calls)
|
|
if call_num == 1:
|
|
return [], find_category_row(labels, "Search & Research")
|
|
if call_num == 2:
|
|
# Search & Research is expanded; select BraveSearchTool and
|
|
# expand Web Scraping instead
|
|
brave = next(
|
|
idx
|
|
for idx, label in enumerate(labels)
|
|
if label.strip().endswith("BraveSearchTool")
|
|
)
|
|
return [brave], find_category_row(labels, "Web Scraping")
|
|
return [], None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
tools = json_crew._select_tools()
|
|
|
|
assert tools == ["BraveSearchTool"]
|
|
assert len(picker_calls) == 3
|
|
# Second render: Search & Research expanded, others collapsed
|
|
labels2 = picker_calls[1][1]
|
|
assert any(label.startswith("▾ Search & Research") for label in labels2)
|
|
assert any(label.strip().endswith("BraveSearchTool") for label in labels2)
|
|
assert any(label.startswith("▸ Web Scraping") for label in labels2)
|
|
# Third render: Web Scraping expanded, Search & Research collapsed again
|
|
labels3 = picker_calls[2][1]
|
|
assert any(label.startswith("▸ Search & Research") for label in labels3)
|
|
assert any(label.startswith("▾ Web Scraping") for label in labels3)
|
|
assert not any(label.strip().endswith("BraveSearchTool") for label in labels3)
|
|
# The collapsed Search & Research row reports its selection count
|
|
assert any(
|
|
"Search & Research" in label and "1 selected" in label for label in labels3
|
|
)
|
|
# Cursor returns to the toggled category row
|
|
assert picker_calls[2][2]["initial_cursor"] == next(
|
|
idx for idx, label in enumerate(labels3) if "Web Scraping" in label
|
|
)
|
|
|
|
|
|
def test_json_wizard_tool_picker_preserves_selection_across_renders(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
call_num = len(picker_calls)
|
|
if call_num == 1:
|
|
# Select a common tool, then expand a category
|
|
category_row = next(
|
|
idx for idx, label in enumerate(labels) if "Web Scraping" in label
|
|
)
|
|
return [1], category_row
|
|
# Confirm without touching anything else
|
|
return sorted(kwargs["preselected"]), None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
tools = json_crew._select_tools()
|
|
|
|
# The common-tool selection survived the expand re-render via preselected
|
|
assert tools == ["SerperDevTool"]
|
|
assert 1 in picker_calls[1][2]["preselected"]
|
|
|
|
|
|
def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
expanded_labels: list[str] = []
|
|
|
|
categories = [
|
|
"Search & Research",
|
|
"Web Scraping",
|
|
"File & Document",
|
|
"Code & Data",
|
|
"Cloud & Storage",
|
|
"Sandbox & Automation",
|
|
"AI & Vision",
|
|
]
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
expanded_labels.extend(labels)
|
|
call_num = len(picker_calls)
|
|
if call_num <= len(categories):
|
|
category = categories[call_num - 1]
|
|
category_row = next(
|
|
idx for idx, label in enumerate(labels) if category in label
|
|
)
|
|
return [], category_row
|
|
return [], None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
json_crew._select_tools()
|
|
|
|
tool_names = {
|
|
label.rsplit(maxsplit=1)[-1]
|
|
for label in expanded_labels
|
|
if not label.startswith(("▸", "▾", "──"))
|
|
}
|
|
|
|
assert {
|
|
"DirectorySearchTool",
|
|
"MDXSearchTool",
|
|
"XMLSearchTool",
|
|
"YoutubeVideoSearchTool",
|
|
"S3ReaderTool",
|
|
"E2BExecTool",
|
|
"TavilyResearchTool",
|
|
"SerplyNewsSearchTool",
|
|
"BrowserbaseLoadTool",
|
|
"PatronusEvalTool",
|
|
}.issubset(tool_names)
|
|
assert {
|
|
"MCPServerAdapter",
|
|
"MongoDBVectorSearchConfig",
|
|
"ScrapegraphScrapeToolSchema",
|
|
"SnowflakeConfig",
|
|
}.isdisjoint(tool_names)
|
|
|
|
|
|
def test_json_wizard_platform_tool_selection_stays_in_agent_tools(monkeypatch):
|
|
picker_calls = 0
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
nonlocal picker_calls
|
|
picker_calls += 1
|
|
if picker_calls == 1:
|
|
platform_row = next(
|
|
idx for idx, label in enumerate(labels) if "CrewAI Platform" in label
|
|
)
|
|
return [], platform_row
|
|
|
|
if picker_calls == 2:
|
|
engineering = next(
|
|
idx
|
|
for idx, label in enumerate(labels)
|
|
if "Engineering, data & infrastructure" in label
|
|
)
|
|
return [], engineering
|
|
|
|
if picker_calls == 3:
|
|
github = next(
|
|
idx
|
|
for idx, label in enumerate(labels)
|
|
if label.strip().startswith("GitHub Integration")
|
|
)
|
|
return [github], None
|
|
|
|
create_repository = next(
|
|
idx
|
|
for idx, label in enumerate(labels)
|
|
if label == "Create repository (create_repository)"
|
|
)
|
|
return [create_repository]
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
monkeypatch.setattr(
|
|
json_crew, "_prompt_text", lambda label, **kwargs: label.lower()
|
|
)
|
|
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
|
|
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
|
|
|
|
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
|
|
|
|
assert agent is not None
|
|
assert agent["tools"] == ["platform:github/create_repository"]
|
|
assert (
|
|
'"tools": ["platform:github/create_repository"]'
|
|
in json_crew._agent_to_jsonc(agent)
|
|
)
|
|
|
|
|
|
def test_platform_applications_expand_in_the_main_tool_picker(monkeypatch):
|
|
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
|
|
|
def pick_many(title: str, labels: list[str], **kwargs):
|
|
picker_calls.append((title, labels, kwargs))
|
|
if len(picker_calls) == 1:
|
|
platform = next(
|
|
index
|
|
for index, label in enumerate(labels)
|
|
if "CrewAI Platform" in label
|
|
)
|
|
return [], platform
|
|
if len(picker_calls) == 2:
|
|
group = next(
|
|
index
|
|
for index, label in enumerate(labels)
|
|
if "Engineering, data & infrastructure" in label
|
|
)
|
|
return [], group
|
|
github = next(
|
|
index
|
|
for index, label in enumerate(labels)
|
|
if label.strip().startswith("GitHub Integration")
|
|
)
|
|
return [github], None
|
|
|
|
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
|
|
|
assert json_crew._select_tools() == ["platform:github"]
|
|
labels = picker_calls[1][1]
|
|
assert labels[0] == "── Common tools ──"
|
|
assert any(
|
|
label == " ▸ Google Workspace & Google Cloud (17 applications)"
|
|
for label in labels
|
|
)
|
|
assert any(
|
|
label == " ▸ Engineering, data & infrastructure (11 applications)"
|
|
for label in labels
|
|
)
|
|
assert picker_calls[1][2]["initial_cursor"] == next(
|
|
index for index, label in enumerate(labels) if "CrewAI Platform" in label
|
|
)
|
|
assert all(title != "Platform applications (space to toggle, enter to return):" for title, *_ in picker_calls)
|
|
|
|
|
|
def test_json_wizard_platform_catalog_contains_every_supported_app():
|
|
platform_category = next(
|
|
tools
|
|
for category, tools in json_crew._TOOL_CATEGORIES
|
|
if category == "CrewAI Platform"
|
|
)
|
|
|
|
assert [name for name, _description in platform_category] == [
|
|
f"platform:{app}" for app in PLATFORM_APPS
|
|
]
|
|
|
|
|
|
def test_platform_validation_deduplicates_actions_by_application() -> None:
|
|
apps = json_crew._platform_apps_from_agents(
|
|
[
|
|
{
|
|
"tools": [
|
|
"platform:github/create_repository",
|
|
"platform:github/get_repository_content",
|
|
"platform:gmail/send_email",
|
|
]
|
|
},
|
|
{"tools": ["platform:github/list_repository_issues"]},
|
|
]
|
|
)
|
|
|
|
assert apps == ["github", "gmail"]
|
|
|
|
|
|
def test_platform_token_prompts_show_entered_values(monkeypatch) -> None:
|
|
prompt_options: list[dict[str, object]] = []
|
|
|
|
def prompt(_label: str, **kwargs: object) -> str:
|
|
prompt_options.append(kwargs)
|
|
return "token"
|
|
|
|
monkeypatch.setattr(json_crew.click, "prompt", prompt)
|
|
|
|
assert json_crew._prompt_platform_token() == "token"
|
|
assert json_crew._prompt_platform_revalidation_token() == "token"
|
|
assert [options["hide_input"] for options in prompt_options] == [False, False]
|
|
|
|
|
|
def test_platform_auth_suppresses_warnings_only_while_importing_tools(
|
|
monkeypatch, capsys
|
|
):
|
|
class FakeApplicationSelector:
|
|
@classmethod
|
|
def from_string(cls, value: str) -> str:
|
|
return value
|
|
|
|
fake_client = mock.Mock()
|
|
fake_client.get_actions.return_value = [object()]
|
|
fake_integrations_client = mock.Mock(
|
|
ApplicationSelector=FakeApplicationSelector,
|
|
client_for_selector=lambda _selector: fake_client,
|
|
)
|
|
original_import = builtins.__import__
|
|
|
|
def import_with_warning(name, globals=None, locals=None, fromlist=(), level=0):
|
|
if name == "crewai_tools.tools.crewai_platform_tools.integrations_client":
|
|
warnings.warn("optional dependency import warning", UserWarning)
|
|
return fake_integrations_client
|
|
return original_import(name, globals, locals, fromlist, level)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", import_with_warning)
|
|
monkeypatch.setenv("CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN", "test-token")
|
|
|
|
with warnings.catch_warnings(record=True) as caught_warnings:
|
|
warnings.simplefilter("always")
|
|
json_crew._setup_platform_auth([{"tools": ["platform:github"]}])
|
|
warnings.warn("warning after import", UserWarning)
|
|
|
|
assert [str(warning.message) for warning in caught_warnings] == [
|
|
"warning after import"
|
|
]
|
|
output = capsys.readouterr().out
|
|
assert "Checking for CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN" in output
|
|
assert (
|
|
"Enterprise Action Auth Token found via "
|
|
"CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN environment variable" in output
|
|
)
|
|
|
|
|
|
def test_platform_validation_checks_apps_concurrently():
|
|
class FakeApplicationSelector:
|
|
@classmethod
|
|
def from_string(cls, value: str) -> str:
|
|
return value
|
|
|
|
barrier = threading.Barrier(2)
|
|
|
|
class FakeClient:
|
|
def get_actions(self, _selectors):
|
|
barrier.wait(timeout=1)
|
|
return [object()]
|
|
|
|
results = asyncio.run(
|
|
json_crew._check_platform_apps_concurrently(
|
|
["github", "gmail"],
|
|
FakeApplicationSelector,
|
|
lambda _selector: FakeClient(),
|
|
)
|
|
)
|
|
|
|
assert all(actions and error is None for actions, error in results)
|
|
|
|
|
|
def test_platform_validation_announces_concurrent_apps_together(capsys):
|
|
class FakeApplicationSelector:
|
|
@classmethod
|
|
def from_string(cls, value: str) -> str:
|
|
return value
|
|
|
|
class FakeClient:
|
|
def get_actions(self, _selectors):
|
|
return [object()]
|
|
|
|
failed_apps, token_invalid = json_crew._validate_platform_apps(
|
|
["github", "gmail", "google_calendar"],
|
|
FakeApplicationSelector,
|
|
lambda _selector: FakeClient(),
|
|
)
|
|
|
|
output = capsys.readouterr().out
|
|
assert (failed_apps, token_invalid) == ([], False)
|
|
assert "Checking GitHub, Gmail, Google Calendar integrations together on AMP" in output
|
|
assert "Checking Enterprise Action Auth Token and GitHub" not in output
|
|
|
|
|
|
def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys):
|
|
class FakeApplicationSelector:
|
|
@classmethod
|
|
def from_string(cls, value: str) -> str:
|
|
return value
|
|
|
|
checked_apps: list[str] = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, app: str):
|
|
self.app = app
|
|
|
|
def get_actions(self, _selectors):
|
|
checked_apps.append(self.app)
|
|
return [object()]
|
|
|
|
monkeypatch.setattr(json_crew.asyncio, "get_running_loop", object)
|
|
|
|
failed_apps, token_invalid = json_crew._validate_platform_apps(
|
|
["github", "gmail"],
|
|
FakeApplicationSelector,
|
|
lambda app: FakeClient(app),
|
|
)
|
|
|
|
assert (failed_apps, token_invalid) == ([], False)
|
|
assert checked_apps == ["github", "gmail"]
|
|
output = capsys.readouterr().out
|
|
assert "Checking Enterprise Action Auth Token and GitHub integration" in output
|
|
assert "Checking Enterprise Action Auth Token and Gmail integration" in output
|
|
|
|
|
|
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
|
|
cursors: list[int] = []
|
|
|
|
monkeypatch.setattr(tui_picker, "_read_key", lambda: "enter")
|
|
monkeypatch.setattr(
|
|
tui_picker,
|
|
"_draw_multi",
|
|
lambda _labels, cursor, *_args, **_kwargs: cursors.append(cursor),
|
|
)
|
|
monkeypatch.setattr(tui_picker, "_clear_lines", lambda *_args, **_kwargs: None)
|
|
|
|
assert tui_picker._arrow_select_multi(
|
|
["── Common tools ──", "Google search via Serper API SerperDevTool"],
|
|
separator_indices={0},
|
|
) == ([], None)
|
|
assert cursors == [1]
|
|
|
|
|
|
def test_json_wizard_agent_attribute_prompts_are_compact(monkeypatch):
|
|
prompt_calls: list[tuple[str, bool]] = []
|
|
prompt_values = {
|
|
"Role": "Senior Dev Rel",
|
|
"Goal": "Draft content",
|
|
"Backstory": "Knows developer communities",
|
|
}
|
|
|
|
def prompt_text(
|
|
label: str,
|
|
default: str = "",
|
|
*,
|
|
spacing_before: bool = True,
|
|
) -> str:
|
|
prompt_calls.append((label, spacing_before))
|
|
return prompt_values[label]
|
|
|
|
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
|
|
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
|
|
monkeypatch.setattr(json_crew, "pick_many", lambda *_args, **_kwargs: ([], None))
|
|
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
|
|
|
|
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
|
|
|
|
assert agent is not None
|
|
assert prompt_calls == [
|
|
("Role", False),
|
|
("Goal", False),
|
|
("Backstory", False),
|
|
]
|
|
|
|
|
|
def test_json_wizard_task_attribute_prompts_are_compact(monkeypatch):
|
|
prompt_calls: list[tuple[str, bool]] = []
|
|
prompt_values = {
|
|
"Description": "Research latest release",
|
|
"Expected output": "Release summary",
|
|
}
|
|
|
|
def prompt_text(
|
|
label: str,
|
|
default: str = "",
|
|
*,
|
|
spacing_before: bool = True,
|
|
) -> str:
|
|
prompt_calls.append((label, spacing_before))
|
|
return prompt_values[label]
|
|
|
|
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
|
|
|
|
task = json_crew._wizard_task(
|
|
task_num=1,
|
|
agent_names=["senior_dev_rel"],
|
|
prior_task_names=[],
|
|
)
|
|
|
|
assert task is not None
|
|
assert prompt_calls == [
|
|
("Description", False),
|
|
("Expected output", False),
|
|
]
|
|
|
|
|
|
def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
with mock.patch(
|
|
"crewai_cli.create_json_crew._wizard_agents_and_tasks"
|
|
) as mock_wizard:
|
|
mock_wizard.return_value = (
|
|
[
|
|
{
|
|
"name": "researcher",
|
|
"role": "Researcher",
|
|
"goal": "Research",
|
|
"backstory": "Researcher",
|
|
"llm": "openai/gpt-5.5",
|
|
"tools": [],
|
|
"planning": False,
|
|
"allow_delegation": False,
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"name": "research_task",
|
|
"description": "Research",
|
|
"expected_output": "Findings",
|
|
"agent": "researcher",
|
|
"context": [],
|
|
}
|
|
],
|
|
{"process": "sequential", "memory": False, "inputs": {}},
|
|
)
|
|
|
|
json_crew.create_json_crew("JSON Crew", provider="openai", skip_provider=True)
|
|
|
|
mock_wizard.assert_called_once_with(
|
|
skip_provider=True,
|
|
default_llm="openai/gpt-5.5",
|
|
)
|
|
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
|
|
assert not (tmp_path / "json_crew" / "src").exists()
|
|
assert not (tmp_path / "json_crew" / "tests").exists()
|
|
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
|
|
generated_paths = {
|
|
path.relative_to(tmp_path / "json_crew").as_posix()
|
|
for path in (tmp_path / "json_crew").rglob("*")
|
|
if path.is_file()
|
|
}
|
|
assert not any(
|
|
path.endswith("/crew.py") or path == "crew.py" for path in generated_paths
|
|
)
|
|
assert not any(
|
|
path.endswith("/agents.yaml") or path == "agents.yaml"
|
|
for path in generated_paths
|
|
)
|
|
assert not any(
|
|
path.endswith("/tasks.yaml") or path == "tasks.yaml"
|
|
for path in generated_paths
|
|
)
|
|
assert not any(path.startswith("src/") for path in generated_paths)
|
|
|
|
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
|
|
dependency = pyproject["project"]["dependencies"][0]
|
|
assert dependency == get_crewai_tools_dependency()
|
|
assert Version("1.15.0") in Requirement(dependency).specifier
|
|
assert Version("2.0.0") not in Requirement(dependency).specifier
|
|
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
|
|
"only-include"
|
|
] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
|
|
assert pyproject["tool"]["crewai"] == {
|
|
"type": "crew",
|
|
"definition": "crew.jsonc",
|
|
}
|
|
|
|
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
|
|
assert (
|
|
'"guardrail": "Every factual claim needs context support."'
|
|
in crew_template
|
|
)
|
|
assert '"guardrails": [' in crew_template
|
|
assert '"guardrail_max_retries": 2' in crew_template
|
|
assert "Docs: https://docs.crewai.com/concepts/tasks" in crew_template
|
|
assert '"output_pydantic": null' in crew_template
|
|
assert '"type": "ConditionalTask"' in crew_template
|
|
assert '"condition": { "python": "my_project.conditions.should_run" }' in (
|
|
crew_template
|
|
)
|
|
assert '"output_json": { "python": "my_project.models.ReportOutput" }' in (
|
|
crew_template
|
|
)
|
|
assert (
|
|
'"converter_cls": { "python": "my_project.converters.CustomConverter" }'
|
|
in crew_template
|
|
)
|
|
assert '"markdown": false' in crew_template
|
|
assert '"input_files": { "brief": "data/brief.txt" }' in crew_template
|
|
assert "Docs: https://docs.crewai.com/concepts/crews" in crew_template
|
|
assert "manager_agent can reference an agents/<name>.jsonc file" in crew_template
|
|
assert '"manager_agent": "researcher"' in crew_template
|
|
assert (
|
|
'"before_kickoff_callbacks": [{"python": '
|
|
'"my_project.callbacks.before_kickoff"}]'
|
|
) in crew_template
|
|
assert (
|
|
'"after_kickoff_callbacks": [{"python": '
|
|
'"my_project.callbacks.after_kickoff"}]'
|
|
) in crew_template
|
|
assert '"output_log_file": "crew.log"' in crew_template
|
|
assert "Crew-level LLM fields also accept object form" in crew_template
|
|
assert '"chat_llm": {"model": "llama3", "provider": "ollama"' in (
|
|
crew_template
|
|
)
|
|
assert "Use {placeholder} in agent or task text" in crew_template
|
|
assert "`crewai run` prompts for any placeholders" in crew_template
|
|
assert "Use {placeholder} inputs here" in crew_template
|
|
|
|
agent_template = (
|
|
tmp_path / "json_crew" / "agents" / "researcher.jsonc"
|
|
).read_text()
|
|
assert "You can use {placeholder} inputs in role, goal, or backstory" in (
|
|
agent_template
|
|
)
|
|
assert '"role": "Senior {industry} Researcher"' in agent_template
|
|
assert '"type": {"python": "my_project.agents.CustomAgent"}' in agent_template
|
|
assert "Optional agent-level guardrail" in agent_template
|
|
assert "Python refs must point to module-level functions/classes" in agent_template
|
|
assert (
|
|
'"step_callback": {"python": "my_project.callbacks.on_agent_step"}'
|
|
in agent_template
|
|
)
|
|
assert '"guardrail_max_retries": 2' in agent_template
|
|
assert "Docs: https://docs.crewai.com/concepts/agents" in agent_template
|
|
assert '"reasoning": true' in agent_template
|
|
assert "For custom endpoints or deployment-based providers" in agent_template
|
|
assert '"deployment_name": "my-deployment", "provider": "azure"' in (
|
|
agent_template
|
|
)
|
|
assert '"planning_config": {' in agent_template
|
|
assert '"llm": {"model": "deepseek-chat", "provider": "deepseek"}' in (
|
|
agent_template
|
|
)
|
|
assert '"knowledge_sources": []' in agent_template
|
|
|
|
|
|
def test_json_create_saves_platform_token_to_env_file(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"_wizard_agents_and_tasks",
|
|
lambda **_: (
|
|
[
|
|
{
|
|
"name": "researcher",
|
|
"role": "Researcher",
|
|
"goal": "Research",
|
|
"backstory": "Researcher",
|
|
"llm": "openai/gpt-5.5",
|
|
"tools": ["platform:github"],
|
|
"planning": False,
|
|
"allow_delegation": False,
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"name": "research_task",
|
|
"description": "Research",
|
|
"expected_output": "Findings",
|
|
"agent": "researcher",
|
|
"context": [],
|
|
}
|
|
],
|
|
{"process": "sequential", "memory": False, "inputs": {}},
|
|
),
|
|
)
|
|
monkeypatch.setattr(json_crew, "_setup_platform_auth", lambda _agents: "token")
|
|
|
|
json_crew.create_json_crew("Platform Crew", skip_provider=True)
|
|
|
|
env_file = tmp_path / "platform_crew" / ".env"
|
|
assert "CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN=token" in env_file.read_text()
|
|
|
|
|
|
def test_json_crew_uses_template_files():
|
|
template_names = {
|
|
"pyproject.toml",
|
|
"README.md",
|
|
".gitignore",
|
|
"agent.jsonc",
|
|
"agent_settings.jsonc",
|
|
"task.jsonc",
|
|
"crew.jsonc",
|
|
"knowledge/user_preference.txt",
|
|
}
|
|
|
|
for template_name in template_names:
|
|
assert (json_crew._TEMPLATES_DIR / template_name).is_file()
|
|
|
|
|
|
def test_render_template_does_not_replace_tokens_inside_replacement_values(tmp_path):
|
|
template = tmp_path / "template.txt"
|
|
template.write_text("{{first}} {{second}}", encoding="utf-8")
|
|
|
|
rendered = render_template(
|
|
template,
|
|
{
|
|
"first": "{{second}}",
|
|
"second": "done",
|
|
},
|
|
)
|
|
|
|
assert rendered == "{{second}} done"
|
|
|
|
|
|
def test_json_provider_default_model_helper():
|
|
assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5"
|
|
assert json_crew._default_model_for_provider("anthropic/claude-custom") == (
|
|
"anthropic/claude-custom"
|
|
)
|
|
assert json_crew._default_model_for_provider("unknown") is None
|
|
|
|
|
|
def test_json_wizard_task_reprompts_on_cancelled_agent_pick(monkeypatch):
|
|
"""Esc on the agent picker must reprompt, not silently assign agent 0."""
|
|
prompts = iter(["Do the research", "A report"])
|
|
monkeypatch.setattr(json_crew, "_prompt_text", lambda *a, **k: next(prompts))
|
|
|
|
pick_calls: list[str] = []
|
|
picks = iter([-1, 1])
|
|
|
|
def fake_pick_one(title: str, labels: list[str]) -> int:
|
|
pick_calls.append(title)
|
|
return next(picks)
|
|
|
|
monkeypatch.setattr(json_crew, "pick_one", fake_pick_one)
|
|
|
|
task = json_crew._wizard_task(
|
|
task_num=1,
|
|
agent_names=["first_agent", "second_agent"],
|
|
prior_task_names=[],
|
|
)
|
|
|
|
assert len(pick_calls) == 2
|
|
assert task["agent"] == "second_agent"
|
|
|
|
|
|
def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("CREWAI_DMN", "True")
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"_wizard_agents_and_tasks",
|
|
lambda **_: pytest.fail("DMN mode must not run the wizard"),
|
|
)
|
|
monkeypatch.setattr(
|
|
json_crew,
|
|
"_setup_env",
|
|
lambda *_args, **_kwargs: pytest.fail("DMN mode must not prompt for env vars"),
|
|
)
|
|
|
|
json_crew.create_json_crew("DMN Crew", provider="anthropic", skip_provider=False)
|
|
|
|
project_root = tmp_path / "dmn_crew"
|
|
assert (project_root / "crew.jsonc").exists()
|
|
assert (project_root / "agents" / "researcher.jsonc").exists()
|
|
assert not (project_root / ".env").exists()
|
|
|
|
crew_template = (project_root / "crew.jsonc").read_text()
|
|
agent_template = (project_root / "agents" / "researcher.jsonc").read_text()
|
|
|
|
assert '"memory": true' in crew_template
|
|
assert '"description": "Research current AI trends and write a concise summary."' in (
|
|
crew_template
|
|
)
|
|
assert '"llm": "anthropic/claude-opus-4-6"' in agent_template
|
|
|
|
|
|
def test_create_crew_scaffolds_assistant_instructions(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
project_root = tmp_path / "my_crew"
|
|
agents_md = (project_root / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert "CrewAI Reference for AI Coding Assistants" in agents_md
|
|
claude_md = (project_root / "CLAUDE.md").read_text(encoding="utf-8")
|
|
assert "@AGENTS.md" in claude_md.splitlines()
|
|
cursor_md = (project_root / "CURSOR.md").read_text(encoding="utf-8")
|
|
assert "@AGENTS.md" in cursor_md.splitlines()
|
|
gemini_md = (project_root / "GEMINI.md").read_text(encoding="utf-8")
|
|
assert "@./AGENTS.md" in gemini_md.splitlines()
|
|
|
|
|
|
def test_scaffolded_agents_md_tells_assistants_to_keep_observability_on(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
agents_md = (tmp_path / "my_crew" / "AGENTS.md").read_text(encoding="utf-8")
|
|
[keep_on] = [
|
|
line
|
|
for line in agents_md.splitlines()
|
|
if "Never disable, block, or silence CrewAI's built-in observability" in line
|
|
]
|
|
assert "any of the instrumentation that ships execution data out" in keep_on
|
|
assert "Turning it off is the user's decision to make" in keep_on
|
|
assert "- Treating built-in observability" in agents_md
|
|
assert "free" not in agents_md.lower()
|
|
|
|
|
|
def test_json_create_scaffolds_assistant_instructions(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
with mock.patch(
|
|
"crewai_cli.create_json_crew._wizard_agents_and_tasks",
|
|
return_value=(
|
|
[
|
|
{
|
|
"name": "researcher",
|
|
"role": "Researcher",
|
|
"goal": "Research",
|
|
"backstory": "Researcher",
|
|
"llm": "openai/gpt-4o",
|
|
"tools": [],
|
|
"planning": False,
|
|
"allow_delegation": False,
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"name": "research_task",
|
|
"description": "Research",
|
|
"expected_output": "Findings",
|
|
"agent": "researcher",
|
|
"context": [],
|
|
}
|
|
],
|
|
{"process": "sequential", "memory": False, "inputs": {}},
|
|
),
|
|
):
|
|
json_crew.create_json_crew("JSON Crew", provider="openai", skip_provider=True)
|
|
|
|
project_root = tmp_path / "json_crew"
|
|
agents_md = (project_root / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert "CrewAI Reference for AI Coding Assistants" in agents_md
|
|
assert "## Evaluating this crew/flow" in agents_md
|
|
assert "crewai-eval:" not in agents_md
|
|
assert "crew.jsonc" in agents_md
|
|
claude_md = (project_root / "CLAUDE.md").read_text(encoding="utf-8")
|
|
assert "@AGENTS.md" in claude_md.splitlines()
|
|
cursor_md = (project_root / "CURSOR.md").read_text(encoding="utf-8")
|
|
assert "@AGENTS.md" in cursor_md.splitlines()
|
|
gemini_md = (project_root / "GEMINI.md").read_text(encoding="utf-8")
|
|
assert "@./AGENTS.md" in gemini_md.splitlines()
|
|
|
|
|
|
def test_scaffolded_agents_md_tells_assistants_to_evaluate_with_crewai_eval(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
agents_md = (tmp_path / "my_crew" / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert "## Evaluating this crew/flow" in agents_md
|
|
assert "Run `crewai eval` after a change" in agents_md
|
|
assert "it prints a **markdown brief** after the verdict" in agents_md
|
|
assert 'crewai eval --models "provider/model,…"' in agents_md
|
|
assert "crewai-eval:" not in agents_md # the markers never reach a project
|
|
|
|
|
|
def _cli_args(line: str) -> list[str]:
|
|
"""Arguments of a documented `crewai …` line, with `<placeholder>` values filled."""
|
|
words = line.split("#", 1)[0].split()[1:]
|
|
return ["value" if re.fullmatch(r"<\w+>", word) else word for word in words]
|
|
|
|
|
|
def _parse_cli_args(args: list[str]) -> None:
|
|
"""Parse ARGS against the real CLI without running the command."""
|
|
command: click.Command = crewai
|
|
name = "crewai"
|
|
while isinstance(command, click.Group):
|
|
name, *args = args
|
|
subcommand = command.get_command(click.Context(command), name)
|
|
assert subcommand is not None, f"unknown command {name!r}"
|
|
command = subcommand
|
|
command.make_context(name, args)
|
|
|
|
|
|
def test_parse_cli_args_rejects_a_positional_deployment_id():
|
|
with pytest.raises(click.UsageError, match="unexpected extra argument"):
|
|
_parse_cli_args(_cli_args("crewai deploy remove <id>"))
|
|
|
|
|
|
def test_scaffolded_agents_md_deploy_and_org_commands_parse_against_the_cli(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
agents_md = (tmp_path / "my_crew" / "AGENTS.md").read_text(encoding="utf-8")
|
|
commands = [
|
|
line.strip()
|
|
for line in agents_md.splitlines()
|
|
if line.strip().startswith(("crewai deploy ", "crewai org "))
|
|
]
|
|
|
|
for documented in (
|
|
"crewai deploy remove -u <uuid>",
|
|
"crewai deploy create -y",
|
|
"crewai org current",
|
|
"crewai org switch <id>",
|
|
):
|
|
assert any(line.startswith(documented) for line in commands), documented
|
|
for line in commands:
|
|
_parse_cli_args(_cli_args(line))
|
|
|
|
|
|
def test_scaffolded_agents_md_deploy_prerequisites_allow_zip_deploys(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
agents_md = (tmp_path / "my_crew" / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert "Code is in a GitHub repository" not in agents_md
|
|
assert "With no `origin`, `crewai deploy create` uploads the project as a ZIP" in agents_md
|
|
assert "on `create`, and again on `push` for ZIP deployments" in agents_md
|
|
|
|
|
|
def test_scaffolded_agents_md_platform_tools_guidance(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
create_crew("my-crew", skip_provider=True)
|
|
|
|
agents_md = (tmp_path / "my_crew" / "AGENTS.md").read_text(encoding="utf-8")
|
|
# PLATFORM_APPS lists the apps, but some of its action names resolve to no tool.
|
|
assert "source of truth" not in agents_md
|
|
assert '`CrewaiPlatformTools(apps=["app/action"])` must return at least one tool' in agents_md
|
|
assert "`CREWAI_PLATFORM_INTEGRATION_TOKEN` in `.env`" in agents_md
|
|
assert "`app[/action][@connection]`" in agents_md
|
|
assert " apps:\n - hubspot/search_companies\n - slack\n" in agents_md
|
|
|
|
|
|
@pytest.mark.parametrize("evaluation", [True, False])
|
|
def test_agents_md_best_practices_are_numbered_without_gaps(tmp_path, evaluation):
|
|
copy_assistant_instructions(tmp_path, evaluation=evaluation)
|
|
|
|
agents_md = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
|
|
section = agents_md.split("## Development Best Practices", 1)[1].split("\n## ", 1)[0]
|
|
numbers = [int(number) for number in re.findall(r"^(\d+)\. ", section, re.M)]
|
|
assert numbers == list(range(1, len(numbers) + 1))
|
|
assert ("`crewai eval`" in section) is evaluation
|
|
assert "crewai test" not in section
|