diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index cfdadc8..f8d2fdb 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -43,9 +43,6 @@ from .paths import ( _config = get_effective_config() apply_config_to_env(_config) -# Backend mode: "sandbox" (with execute) or "filesystem" (read/write only) -BACKEND_MODE = "sandbox" - # Research limits (from config) MAX_CONCURRENT = _config.max_concurrent MAX_ITERATIONS = _config.max_iterations @@ -78,18 +75,12 @@ chat_model = get_chat_model( provider=_config.provider, ) -# Initialize workspace backend based on mode -if BACKEND_MODE == "sandbox": - _workspace_backend = CustomSandboxBackend( - root_dir=WORKSPACE_DIR, - virtual_mode=True, - timeout=300, - ) -else: - _workspace_backend = FilesystemBackend( - root_dir=WORKSPACE_DIR, - virtual_mode=True, - ) +# Initialize workspace backend +_workspace_backend = CustomSandboxBackend( + root_dir=WORKSPACE_DIR, + virtual_mode=True, + timeout=300, +) # Skills backend: merge user-installed (./skills/) and system (package) skills _skills_backend = MergedReadOnlyBackend( diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py index ef2d113..2a5af27 100644 --- a/EvoScientist/__init__.py +++ b/EvoScientist/__init__.py @@ -43,6 +43,17 @@ _EXPORTS: dict[str, tuple[str, str]] = { def __getattr__(name: str): + """Lazily import and cache package-level attributes. + + Args: + name: The attribute name to look up. + + Returns: + The resolved attribute value. + + Raises: + AttributeError: If the name is not in _EXPORTS. + """ target = _EXPORTS.get(name) if target is None: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -56,6 +67,7 @@ def __getattr__(name: str): def __dir__() -> list[str]: + """List available public attributes including lazy exports.""" return sorted(set(globals()) | set(_EXPORTS)) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 8dffac7..bc387e0 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -41,7 +41,7 @@ def onboard( Guides you through configuring API keys, model selection, workspace settings, and agent parameters. """ - from ..onboard import run_onboard + from ..config import run_onboard run_onboard(skip_validation=skip_validation) @@ -194,7 +194,7 @@ def mcp_add( target: str = typer.Argument(..., help="Command (stdio) or URL (http/sse)"), args: Optional[list[str]] = typer.Argument(None, help="Extra args for stdio command"), transport: Optional[str] = typer.Option(None, "--transport", "-T", help="Transport type (default: auto-detect)"), - tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist"), + tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist (supports wildcards: *_exa, read_*)"), expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents"), header: Optional[list[str]] = typer.Option(None, "--header", "-H", help="HTTP header as Key:Value (repeatable)"), env: Optional[list[str]] = typer.Option(None, "--env", help="Env var as KEY=VALUE for stdio (repeatable)"), @@ -244,7 +244,7 @@ def mcp_edit( transport: Optional[str] = typer.Option(None, "--transport", help="New transport type"), command: Optional[str] = typer.Option(None, "--command", help="New command (stdio)"), url: Optional[str] = typer.Option(None, "--url", help="New URL (http/sse/websocket)"), - tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist ('none' to clear)"), + tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist, supports wildcards ('none' to clear)"), expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents ('none' to clear)"), header: Optional[list[str]] = typer.Option(None, "--header", "-H", help="HTTP header as Key:Value (repeatable)"), env: Optional[list[str]] = typer.Option(None, "--env", help="Env var as KEY=VALUE for stdio (repeatable)"), diff --git a/EvoScientist/cli/mcp_ui.py b/EvoScientist/cli/mcp_ui.py index a2903e8..5bdf36d 100644 --- a/EvoScientist/cli/mcp_ui.py +++ b/EvoScientist/cli/mcp_ui.py @@ -194,7 +194,7 @@ def _cmd_mcp_add(args_str: str) -> None: console.print() console.print("[dim]Options:[/dim]") console.print(" --transport T Transport type (default: auto-detect)") - console.print(" --tools t1,t2 Tool allowlist") + console.print(" --tools t1,t2 Tool allowlist (supports wildcards: *_exa, read_*)") console.print(" --expose-to a1,a2 Target agents (default: main)") console.print(" --header Key:Value HTTP header (repeatable)") console.print(" --env KEY=VALUE Env var for stdio (repeatable)") diff --git a/EvoScientist/config/__init__.py b/EvoScientist/config/__init__.py new file mode 100644 index 0000000..9f79b57 --- /dev/null +++ b/EvoScientist/config/__init__.py @@ -0,0 +1,38 @@ +"""Configuration package for EvoScientist. + +Re-exports all public symbols from settings and onboard submodules +so that existing ``from EvoScientist.config import X`` imports continue +to work without modification. +""" + +from .settings import ( + get_config_dir, + get_config_path, + EvoScientistConfig, + load_config, + save_config, + reset_config, + get_config_value, + set_config_value, + list_config, + get_effective_config, + apply_config_to_env, +) +from .onboard import run_onboard + +__all__ = [ + # settings + "get_config_dir", + "get_config_path", + "EvoScientistConfig", + "load_config", + "save_config", + "reset_config", + "get_config_value", + "set_config_value", + "list_config", + "get_effective_config", + "apply_config_to_env", + # onboard + "run_onboard", +] diff --git a/EvoScientist/onboard.py b/EvoScientist/config/onboard.py similarity index 99% rename from EvoScientist/onboard.py rename to EvoScientist/config/onboard.py index 7804adb..87bcaab 100644 --- a/EvoScientist/onboard.py +++ b/EvoScientist/config/onboard.py @@ -20,13 +20,13 @@ from rich.console import Console from rich.panel import Panel from rich.text import Text -from .config import ( +from .settings import ( EvoScientistConfig, load_config, save_config, get_config_path, ) -from .llm import get_models_for_provider +from ..llm import get_models_for_provider console = Console() @@ -919,7 +919,7 @@ def _step_skills() -> list[str]: Returns: List of skill sources that were selected (empty if skipped). """ - from .paths import USER_SKILLS_DIR + from ..paths import USER_SKILLS_DIR # Collect names of already-installed user skills skills_dir = Path(USER_SKILLS_DIR) @@ -975,7 +975,7 @@ def _step_skills() -> list[str]: return [] - from .tools.skills_manager import install_skill + from ..tools.skills_manager import install_skill installed = [] for source in selected: @@ -1070,7 +1070,7 @@ def _step_mcp_servers() -> list[str]: Returns: List of server names that were installed. """ - from .mcp.client import _load_user_config, add_mcp_server + from ..mcp.client import _load_user_config, add_mcp_server existing_config = _load_user_config() @@ -1177,7 +1177,7 @@ def validate_imessage() -> tuple[bool, str]: if sys.platform != "darwin": return False, "iMessage requires macOS" - from .channels.imessage.probe import find_cli + from ..channels.imessage.probe import find_cli cli_path = find_cli() if not cli_path: diff --git a/EvoScientist/config.py b/EvoScientist/config/settings.py similarity index 91% rename from EvoScientist/config.py rename to EvoScientist/config/settings.py index 3684ebb..397f6c4 100644 --- a/EvoScientist/config.py +++ b/EvoScientist/config/settings.py @@ -147,6 +147,29 @@ def reset_config() -> None: # Config value operations # ============================================================================= +def _coerce_value(value: Any, field_type: Any) -> Any: + """Coerce a value to the expected field type. + + Args: + value: The value to coerce. + field_type: The target type (from dataclass field). + + Returns: + The coerced value. + + Raises: + ValueError: If the value cannot be coerced. + TypeError: If the value cannot be coerced. + """ + if field_type == "bool" or field_type is bool: + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) + if field_type == "int" or field_type is int: + return int(value) + return str(value) + + def get_config_value(key: str) -> Any: """Get a single configuration value. @@ -181,15 +204,7 @@ def set_config_value(key: str, value: Any) -> bool: field_type = field_info.type try: - if field_type == "bool" or field_type is bool: - if isinstance(value, str): - value = value.lower() in ("true", "1", "yes", "on") - else: - value = bool(value) - elif field_type == "int" or field_type is int: - value = int(value) - elif field_type == "str" or field_type is str: - value = str(value) + value = _coerce_value(value, field_type) except (ValueError, TypeError): return False @@ -250,18 +265,11 @@ def get_effective_config(cli_overrides: dict[str, Any] | None = None) -> EvoScie for config_key, env_key in _ENV_MAPPINGS.items(): env_value = os.environ.get(env_key) if env_value: - # Type coercion field_info = next(f for f in fields(EvoScientistConfig) if f.name == config_key) - field_type = field_info.type - if field_type == "bool" or field_type is bool: - data[config_key] = env_value.lower() in ("true", "1", "yes", "on") - elif field_type == "int" or field_type is int: - try: - data[config_key] = int(env_value) - except ValueError: - pass - else: - data[config_key] = env_value + try: + data[config_key] = _coerce_value(env_value, field_info.type) + except (ValueError, TypeError): + pass # Apply CLI overrides (highest priority) if cli_overrides: diff --git a/EvoScientist/mcp/README.md b/EvoScientist/mcp/README.md index 58762de..955e010 100644 --- a/EvoScientist/mcp/README.md +++ b/EvoScientist/mcp/README.md @@ -44,7 +44,7 @@ More servers: [MCP Server Directory](https://github.com/modelcontextprotocol/ser | `env` | No | Environment variables for subprocess | | `url` | http/sse/ws | Server URL | | `headers` | No | HTTP headers (e.g. auth tokens) | -| `tools` | No | Tool allowlist (omit = all tools) | +| `tools` | No | Tool allowlist with glob wildcards (omit = all tools) | | `expose_to` | No | Target agents (default: `["main"]`) | ## Tool Routing diff --git a/EvoScientist/prompts.py b/EvoScientist/prompts.py index 10a782f..a20288c 100644 --- a/EvoScientist/prompts.py +++ b/EvoScientist/prompts.py @@ -265,13 +265,17 @@ Finding one with context [1]. Another insight [2]. # ============================================================================= def get_system_prompt(max_concurrent: int = 3, max_iterations: int = 3) -> str: - """Generate the complete system prompt with configured limits.""" + """Generate the complete system prompt with configured limits. + + Args: + max_concurrent: Maximum number of concurrent sub-agents. + max_iterations: Maximum number of delegation rounds. + + Returns: + Combined system prompt string. + """ delegation = DELEGATION_STRATEGY.format( max_concurrent=max_concurrent, max_iterations=max_iterations, ) return EXPERIMENT_WORKFLOW + "\n" + delegation - - -# Default export (backward compatible) -SYSTEM_PROMPT = get_system_prompt() diff --git a/EvoScientist/stream/formatter.py b/EvoScientist/stream/formatter.py index 8035482..a42529c 100644 --- a/EvoScientist/stream/formatter.py +++ b/EvoScientist/stream/formatter.py @@ -68,14 +68,10 @@ class ToolResultFormatter: return ContentType.TEXT - def is_success(self, content: str) -> bool: - """Check if content indicates successful execution.""" - return _is_success(content) - def format(self, name: str, content: str, max_length: int = 800) -> FormattedResult: """Format tool result based on detected content type.""" content_type = self.detect_type(content) - success = self.is_success(content) + success = _is_success(content) formatter_map = { ContentType.SUCCESS: self._format_success, diff --git a/EvoScientist/utils.py b/EvoScientist/utils.py index 7222300..6a105c6 100644 --- a/EvoScientist/utils.py +++ b/EvoScientist/utils.py @@ -17,7 +17,14 @@ console = Console() def format_message_content(message): - """Convert message content to displayable string.""" + """Convert message content to displayable string. + + Args: + message: A LangChain message object with content attribute. + + Returns: + Formatted string representation of the message content. + """ parts = [] tool_calls_processed = False @@ -52,7 +59,11 @@ def format_message_content(message): def format_messages(messages): - """Format and display a list of messages with Rich formatting.""" + """Format and display a list of messages with Rich formatting. + + Args: + messages: List of LangChain message objects to display. + """ for m in messages: msg_type = m.__class__.__name__.replace("Message", "") content = format_message_content(m) @@ -67,10 +78,6 @@ def format_messages(messages): console.print(Panel(content, title=f"📝 {msg_type}", border_style="white")) -def format_message(messages): - """Alias for format_messages for backward compatibility.""" - return format_messages(messages) - def show_prompt(prompt_text: str, title: str = "Prompt", border_style: str = "blue"): """Display a prompt with rich formatting and XML tag highlighting. diff --git a/README.md b/README.md index ba90e00..22375c3 100644 --- a/README.md +++ b/README.md @@ -326,8 +326,8 @@ EvoSci mcp add brave-search http://localhost:8080/mcp -H "Authorization:Bearer $ # sse transport, routed to a specific agent EvoSci mcp add my-sse http://localhost:9090/sse --transport sse -e research-agent -# With tool allowlist -EvoSci mcp add fs npx -- -y @modelcontextprotocol/server-filesystem /tmp -t read_file,write_file +# With tool allowlist (supports glob wildcards) +EvoSci mcp add fs npx -- -y @modelcontextprotocol/server-filesystem /tmp -t "read_*,write_*" ``` Or from the interactive CLI: @@ -342,7 +342,7 @@ Or from the interactive CLI: | Flag | Description | |------|-------------| -| `--tools`, `-t` | Comma-separated tool allowlist (omit = all tools) | +| `--tools`, `-t` | Comma-separated tool allowlist, supports glob wildcards (omit = all tools) | | `--expose-to`, `-e` | Comma-separated target agents (default: `main`) | | `--header`, `-H` | HTTP header as `Key:Value` (repeatable) | | `--env` | Env var as `KEY=VALUE` for stdio (repeatable) | @@ -356,7 +356,7 @@ filesystem: transport: stdio command: npx args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] - tools: [read_file, write_file] # optional allowlist (omit = all tools) + tools: ["read_*", "write_*"] # optional allowlist with wildcards (omit = all tools) expose_to: [main, code-agent] # optional routing (omit = ["main"]) brave-search: @@ -395,8 +395,8 @@ Update individual fields on an existing server without re-adding it: # Change routing EvoSci mcp edit filesystem --expose-to main,code-agent -# Set a tool allowlist -EvoSci mcp edit filesystem --tools read_file,write_file +# Set a tool allowlist (supports glob wildcards) +EvoSci mcp edit filesystem --tools "read_*,write_*" # Clear a tool allowlist (pass all tools) EvoSci mcp edit filesystem --tools none diff --git a/tests/test_llm.py b/tests/test_llm.py index 3ec89f2..872af2f 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,6 +1,6 @@ """Tests for EvoScientist LLM module.""" -from unittest import mock +from unittest.mock import patch from EvoScientist.llm import ( MODELS, @@ -122,7 +122,7 @@ class TestGetModelInfo: class TestGetChatModel: - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_uses_default_model_when_none(self, mock_init): """Test that get_chat_model uses default model when model=None.""" mock_init.return_value = "mock_model" @@ -136,7 +136,7 @@ class TestGetChatModel: assert call_kwargs["model"] == expected_model_id assert call_kwargs["model_provider"] == expected_provider - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_resolves_short_name(self, mock_init): """Test that get_chat_model resolves short names correctly.""" mock_init.return_value = "mock_model" @@ -147,7 +147,7 @@ class TestGetChatModel: assert call_kwargs["model"] == "claude-opus-4-5-20251101" assert call_kwargs["model_provider"] == "anthropic" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_resolves_openai_short_name(self, mock_init): """Test that get_chat_model resolves OpenAI short names.""" mock_init.return_value = "mock_model" @@ -158,7 +158,7 @@ class TestGetChatModel: assert call_kwargs["model"] == "gpt-5-mini-2025-08-07" assert call_kwargs["model_provider"] == "openai" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_uses_full_model_id(self, mock_init): """Test that get_chat_model accepts full model IDs.""" mock_init.return_value = "mock_model" @@ -170,7 +170,7 @@ class TestGetChatModel: # Should infer anthropic from the model prefix assert call_kwargs["model_provider"] == "anthropic" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_provider_override(self, mock_init): """Test that provider can be overridden.""" mock_init.return_value = "mock_model" @@ -180,7 +180,7 @@ class TestGetChatModel: call_kwargs = mock_init.call_args[1] assert call_kwargs["model_provider"] == "custom_provider" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_passes_kwargs(self, mock_init): """Test that additional kwargs are passed through.""" mock_init.return_value = "mock_model" @@ -191,7 +191,7 @@ class TestGetChatModel: assert call_kwargs["temperature"] == 0.7 assert call_kwargs["max_tokens"] == 1000 - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_infers_openai_from_gpt_prefix(self, mock_init): """Test that OpenAI is inferred from gpt- prefix.""" mock_init.return_value = "mock_model" @@ -201,7 +201,7 @@ class TestGetChatModel: call_kwargs = mock_init.call_args[1] assert call_kwargs["model_provider"] == "openai" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_infers_openai_from_o1_prefix(self, mock_init): """Test that OpenAI is inferred from o1 prefix.""" mock_init.return_value = "mock_model" @@ -211,7 +211,7 @@ class TestGetChatModel: call_kwargs = mock_init.call_args[1] assert call_kwargs["model_provider"] == "openai" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_infers_google_from_gemini_prefix(self, mock_init): """Test that google-genai is inferred from gemini prefix.""" mock_init.return_value = "mock_model" @@ -221,7 +221,7 @@ class TestGetChatModel: call_kwargs = mock_init.call_args[1] assert call_kwargs["model_provider"] == "google-genai" - @mock.patch("EvoScientist.llm.models.init_chat_model") + @patch("EvoScientist.llm.models.init_chat_model") def test_defaults_to_anthropic_for_unknown(self, mock_init): """Test that anthropic is default for unknown model prefixes.""" mock_init.return_value = "mock_model" diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 66ddbd1..c981a18 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -1,11 +1,11 @@ """Tests for EvoScientist onboarding wizard.""" import subprocess -from unittest import mock +from unittest.mock import patch, Mock, MagicMock import pytest -from EvoScientist.onboard import ( +from EvoScientist.config.onboard import ( IntegerValidator, ChoiceValidator, STEPS, @@ -207,11 +207,11 @@ class TestChoiceValidator: class TestStepProvider: def test_returns_selected_provider(self): """Test that _step_provider returns selected provider.""" - from EvoScientist.onboard import _step_provider + from EvoScientist.config.onboard import _step_provider config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "anthropic" result = _step_provider(config) @@ -220,11 +220,11 @@ class TestStepProvider: def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_provider raises KeyboardInterrupt on cancel.""" - from EvoScientist.onboard import _step_provider + from EvoScientist.config.onboard import _step_provider config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_provider(config) @@ -233,11 +233,11 @@ class TestStepProvider: class TestStepModel: def test_returns_selected_model(self): """Test that _step_model returns selected model.""" - from EvoScientist.onboard import _step_model + from EvoScientist.config.onboard import _step_model config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "claude-sonnet-4-5" result = _step_model(config, "anthropic") @@ -245,11 +245,11 @@ class TestStepModel: def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_model raises KeyboardInterrupt on cancel.""" - from EvoScientist.onboard import _step_model + from EvoScientist.config.onboard import _step_model config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_model(config, "anthropic") @@ -258,11 +258,11 @@ class TestStepModel: class TestStepWorkspace: def test_returns_mode_and_empty_workdir(self): """Test workspace step with no custom directory.""" - from EvoScientist.onboard import _step_workspace + from EvoScientist.config.onboard import _step_workspace config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "daemon" mock_q.confirm.return_value.ask.return_value = False # No custom dir result = _step_workspace(config) @@ -271,11 +271,11 @@ class TestStepWorkspace: def test_returns_mode_and_custom_workdir(self): """Test workspace step with custom directory.""" - from EvoScientist.onboard import _step_workspace + from EvoScientist.config.onboard import _step_workspace config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "run" mock_q.confirm.return_value.ask.return_value = True # Use custom dir mock_q.text.return_value.ask.return_value = "/custom/path" @@ -287,14 +287,14 @@ class TestStepWorkspace: class TestValidateImessage: def test_valid_when_cli_found_with_rpc(self): """Test validate_imessage returns valid when imsg CLI found and RPC works.""" - from EvoScientist.onboard import validate_imessage + from EvoScientist.config.onboard import validate_imessage - version_result = mock.Mock(returncode=0, stdout="imsg 1.2.3") - rpc_result = mock.Mock(returncode=0) + version_result = Mock(returncode=0, stdout="imsg 1.2.3") + rpc_result = Mock(returncode=0) - with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ - mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ - mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + with patch("EvoScientist.config.onboard.sys") as mock_sys, \ + patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ + patch("EvoScientist.config.onboard.subprocess") as mock_sub: mock_sys.platform = "darwin" mock_shutil.which.return_value = "/opt/homebrew/bin/imsg" mock_sub.run.side_effect = [version_result, rpc_result] @@ -306,10 +306,10 @@ class TestValidateImessage: def test_invalid_when_cli_not_found(self): """Test validate_imessage returns not_installed when imsg CLI missing.""" - from EvoScientist.onboard import validate_imessage + from EvoScientist.config.onboard import validate_imessage - with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ - mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil: + with patch("EvoScientist.config.onboard.sys") as mock_sys, \ + patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil: mock_sys.platform = "darwin" mock_shutil.which.return_value = None valid, msg = validate_imessage() @@ -319,9 +319,9 @@ class TestValidateImessage: def test_invalid_on_non_macos(self): """Test validate_imessage returns invalid on non-macOS.""" - from EvoScientist.onboard import validate_imessage + from EvoScientist.config.onboard import validate_imessage - with mock.patch("EvoScientist.onboard.sys") as mock_sys: + with patch("EvoScientist.config.onboard.sys") as mock_sys: mock_sys.platform = "linux" valid, msg = validate_imessage() @@ -330,14 +330,14 @@ class TestValidateImessage: def test_invalid_when_rpc_not_supported(self): """Test validate_imessage returns invalid when RPC check fails.""" - from EvoScientist.onboard import validate_imessage + from EvoScientist.config.onboard import validate_imessage - version_result = mock.Mock(returncode=0, stdout="imsg 0.1.0") - rpc_result = mock.Mock(returncode=1) + version_result = Mock(returncode=0, stdout="imsg 0.1.0") + rpc_result = Mock(returncode=1) - with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ - mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ - mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + with patch("EvoScientist.config.onboard.sys") as mock_sys, \ + patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ + patch("EvoScientist.config.onboard.subprocess") as mock_sub: mock_sys.platform = "darwin" mock_shutil.which.return_value = "/usr/local/bin/imsg" mock_sub.run.side_effect = [version_result, rpc_result] @@ -350,10 +350,10 @@ class TestValidateImessage: class TestInstallImsg: def test_install_success(self): """Test _install_imsg returns True on success.""" - from EvoScientist.onboard import _install_imsg + from EvoScientist.config.onboard import _install_imsg - with mock.patch("EvoScientist.onboard.subprocess") as mock_sub: - mock_sub.run.return_value = mock.Mock(returncode=0) + with patch("EvoScientist.config.onboard.subprocess") as mock_sub: + mock_sub.run.return_value = Mock(returncode=0) mock_sub.TimeoutExpired = subprocess.TimeoutExpired result = _install_imsg() @@ -361,10 +361,10 @@ class TestInstallImsg: def test_install_brew_not_found(self): """Test _install_imsg handles missing Homebrew.""" - from EvoScientist.onboard import _install_imsg + from EvoScientist.config.onboard import _install_imsg - with mock.patch("EvoScientist.onboard.subprocess") as mock_sub, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.subprocess") as mock_sub, \ + patch("EvoScientist.config.onboard.console"): mock_sub.run.side_effect = FileNotFoundError() mock_sub.TimeoutExpired = subprocess.TimeoutExpired result = _install_imsg() @@ -373,10 +373,10 @@ class TestInstallImsg: def test_install_failure(self): """Test _install_imsg returns False on non-zero exit.""" - from EvoScientist.onboard import _install_imsg + from EvoScientist.config.onboard import _install_imsg - with mock.patch("EvoScientist.onboard.subprocess") as mock_sub: - mock_sub.run.return_value = mock.Mock(returncode=1) + with patch("EvoScientist.config.onboard.subprocess") as mock_sub: + mock_sub.run.return_value = Mock(returncode=1) mock_sub.TimeoutExpired = subprocess.TimeoutExpired result = _install_imsg() @@ -386,32 +386,32 @@ class TestInstallImsg: class TestSetupImessage: def test_already_installed(self): """Test _setup_imessage returns True when already installed.""" - from EvoScientist.onboard import _setup_imessage + from EvoScientist.config.onboard import _setup_imessage - with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(True, "imsg at /bin/imsg")), \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.validate_imessage", return_value=(True, "imsg at /bin/imsg")), \ + patch("EvoScientist.config.onboard.console"): result = _setup_imessage() assert result is True def test_not_macos(self): """Test _setup_imessage returns False on non-macOS.""" - from EvoScientist.onboard import _setup_imessage + from EvoScientist.config.onboard import _setup_imessage - with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "iMessage requires macOS")), \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.validate_imessage", return_value=(False, "iMessage requires macOS")), \ + patch("EvoScientist.config.onboard.console"): result = _setup_imessage() assert result is False def test_install_then_valid(self): """Test _setup_imessage installs and re-validates successfully.""" - from EvoScientist.onboard import _setup_imessage + from EvoScientist.config.onboard import _setup_imessage - with mock.patch("EvoScientist.onboard.validate_imessage") as mock_val, \ - mock.patch("EvoScientist.onboard._install_imsg", return_value=True), \ - mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.validate_imessage") as mock_val, \ + patch("EvoScientist.config.onboard._install_imsg", return_value=True), \ + patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"): mock_val.side_effect = [ (False, "not_installed"), # First check (True, "imsg at /bin/imsg"), # After install @@ -423,11 +423,11 @@ class TestSetupImessage: def test_user_declines_install(self): """Test _setup_imessage returns False when user declines install.""" - from EvoScientist.onboard import _setup_imessage + from EvoScientist.config.onboard import _setup_imessage - with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "not_installed")), \ - mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.validate_imessage", return_value=(False, "not_installed")), \ + patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"): mock_q.confirm.return_value.ask.return_value = False result = _setup_imessage() @@ -437,10 +437,10 @@ class TestSetupImessage: class TestStepSkills: def test_returns_empty_when_none_selected(self): """Test skills step returns empty list when user selects nothing.""" - from EvoScientist.onboard import _step_skills + from EvoScientist.config.onboard import _step_skills - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"): mock_q.checkbox.return_value.ask.return_value = [] result = _step_skills() @@ -448,13 +448,13 @@ class TestStepSkills: def test_installs_selected_skills(self): """Test skills step installs selected skills and returns sources.""" - from EvoScientist.onboard import _step_skills, _RECOMMENDED_SKILLS + from EvoScientist.config.onboard import _step_skills, _RECOMMENDED_SKILLS source = _RECOMMENDED_SKILLS[0]["source"] - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.console"), \ - mock.patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"), \ + patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: mock_q.checkbox.return_value.ask.return_value = [source] mock_install.return_value = {"success": True, "name": "test"} result = _step_skills() @@ -464,13 +464,13 @@ class TestStepSkills: def test_handles_install_failure(self): """Test skills step handles installation errors gracefully.""" - from EvoScientist.onboard import _step_skills, _RECOMMENDED_SKILLS + from EvoScientist.config.onboard import _step_skills, _RECOMMENDED_SKILLS source = _RECOMMENDED_SKILLS[0]["source"] - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.console"), \ - mock.patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"), \ + patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: mock_q.checkbox.return_value.ask.return_value = [source] mock_install.side_effect = Exception("network error") result = _step_skills() @@ -479,9 +479,9 @@ class TestStepSkills: def test_raises_keyboard_interrupt_on_cancel(self): """Test skills step raises KeyboardInterrupt on cancel.""" - from EvoScientist.onboard import _step_skills + from EvoScientist.config.onboard import _step_skills - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.checkbox.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_skills() @@ -490,11 +490,11 @@ class TestStepSkills: class TestStepChannels: def test_returns_disabled_when_skip(self): """Test channels step returns disabled when user selects skip.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "skip" result = _step_channels(config) @@ -502,17 +502,17 @@ class TestStepChannels: def test_returns_enabled_when_setup_passes(self): """Test channels step returns enabled when setup succeeds.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - select_mock_1 = mock.MagicMock() + select_mock_1 = MagicMock() select_mock_1.ask.return_value = "imessage" - select_mock_2 = mock.MagicMock() + select_mock_2 = MagicMock() select_mock_2.ask.return_value = True - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard._setup_imessage", return_value=True): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard._setup_imessage", return_value=True): mock_q.select.side_effect = [select_mock_1, select_mock_2] mock_q.text.return_value.ask.return_value = "" result = _step_channels(config) @@ -521,17 +521,17 @@ class TestStepChannels: def test_returns_enabled_with_senders(self): """Test channels step returns enabled with specific senders.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - select_mock_1 = mock.MagicMock() + select_mock_1 = MagicMock() select_mock_1.ask.return_value = "imessage" - select_mock_2 = mock.MagicMock() + select_mock_2 = MagicMock() select_mock_2.ask.return_value = False - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard._setup_imessage", return_value=True): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard._setup_imessage", return_value=True): mock_q.select.side_effect = [select_mock_1, select_mock_2] mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321" result = _step_channels(config) @@ -540,12 +540,12 @@ class TestStepChannels: def test_setup_fails_user_declines(self): """Test channels step returns disabled when setup fails and user declines.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard._setup_imessage", return_value=False): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard._setup_imessage", return_value=False): mock_q.select.return_value.ask.return_value = "imessage" mock_q.confirm.return_value.ask.return_value = False result = _step_channels(config) @@ -554,17 +554,17 @@ class TestStepChannels: def test_setup_fails_user_enables_anyway(self): """Test channels step enables when setup fails but user confirms.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - select_mock_1 = mock.MagicMock() + select_mock_1 = MagicMock() select_mock_1.ask.return_value = "imessage" - select_mock_2 = mock.MagicMock() + select_mock_2 = MagicMock() select_mock_2.ask.return_value = True - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard._setup_imessage", return_value=False): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard._setup_imessage", return_value=False): mock_q.select.side_effect = [select_mock_1, select_mock_2] mock_q.confirm.return_value.ask.return_value = True mock_q.text.return_value.ask.return_value = "" @@ -574,11 +574,11 @@ class TestStepChannels: def test_raises_keyboard_interrupt_on_cancel(self): """Test channels step raises KeyboardInterrupt on cancel.""" - from EvoScientist.onboard import _step_channels + from EvoScientist.config.onboard import _step_channels config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.select.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_channels(config) @@ -587,18 +587,18 @@ class TestStepChannels: class TestStepMcpServersNpxFailure: def test_npx_failure_skips_npx_servers(self): """When _ensure_npx returns False, npx-dependent servers must be skipped.""" - from EvoScientist.onboard import _step_mcp_servers, _RECOMMENDED_MCP_SERVERS + from EvoScientist.config.onboard import _step_mcp_servers, _RECOMMENDED_MCP_SERVERS # Pick server names: one npx-based, one non-npx (URL-based) npx_name = next(s["name"] for s in _RECOMMENDED_MCP_SERVERS if s.get("command") == "npx") url_name = next(s["name"] for s in _RECOMMENDED_MCP_SERVERS if "url" in s) - with mock.patch("EvoScientist.onboard._checkbox_ask", return_value=[npx_name, url_name]), \ - mock.patch("EvoScientist.onboard._ensure_npx", return_value=False), \ - mock.patch("EvoScientist.onboard._check_npx", return_value=False), \ - mock.patch("EvoScientist.mcp.client._load_user_config", return_value={}), \ - mock.patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard._checkbox_ask", return_value=[npx_name, url_name]), \ + patch("EvoScientist.config.onboard._ensure_npx", return_value=False), \ + patch("EvoScientist.config.onboard._check_npx", return_value=False), \ + patch("EvoScientist.mcp.client._load_user_config", return_value={}), \ + patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, \ + patch("EvoScientist.config.onboard.console"): result = _step_mcp_servers() # The npx server must NOT have been added @@ -611,17 +611,17 @@ class TestStepMcpServersNpxFailure: def test_npx_failure_returns_empty_when_all_npx(self): """When all selected servers are npx-based and npx fails, return [].""" - from EvoScientist.onboard import _step_mcp_servers, _RECOMMENDED_MCP_SERVERS + from EvoScientist.config.onboard import _step_mcp_servers, _RECOMMENDED_MCP_SERVERS npx_names = [s["name"] for s in _RECOMMENDED_MCP_SERVERS if s.get("command") == "npx"] assert len(npx_names) >= 1, "Test requires at least one npx server" - with mock.patch("EvoScientist.onboard._checkbox_ask", return_value=npx_names), \ - mock.patch("EvoScientist.onboard._ensure_npx", return_value=False), \ - mock.patch("EvoScientist.onboard._check_npx", return_value=False), \ - mock.patch("EvoScientist.mcp.client._load_user_config", return_value={}), \ - mock.patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard._checkbox_ask", return_value=npx_names), \ + patch("EvoScientist.config.onboard._ensure_npx", return_value=False), \ + patch("EvoScientist.config.onboard._check_npx", return_value=False), \ + patch("EvoScientist.mcp.client._load_user_config", return_value={}), \ + patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, \ + patch("EvoScientist.config.onboard.console"): result = _step_mcp_servers() assert result == [] @@ -631,11 +631,11 @@ class TestStepMcpServersNpxFailure: class TestStepParameters: def test_returns_parameters(self): """Test parameters step returns all values.""" - from EvoScientist.onboard import _step_parameters + from EvoScientist.config.onboard import _step_parameters config = EvoScientistConfig() - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.text.return_value.ask.side_effect = ["5", "3"] mock_q.select.return_value.ask.return_value = True # show_thinking result = _step_parameters(config) @@ -644,11 +644,11 @@ class TestStepParameters: def test_uses_defaults_on_empty_input(self): """Test that empty input uses defaults.""" - from EvoScientist.onboard import _step_parameters + from EvoScientist.config.onboard import _step_parameters config = EvoScientistConfig(max_concurrent=2, max_iterations=4, show_thinking=False) - with mock.patch("EvoScientist.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.questionary") as mock_q: mock_q.text.return_value.ask.side_effect = ["", ""] # Empty inputs mock_q.select.return_value.ask.return_value = False # show_thinking result = _step_parameters(config) @@ -664,12 +664,12 @@ class TestStepParameters: class TestRunOnboard: def test_returns_true_on_save(self): """Test that run_onboard returns True when config is saved.""" - from EvoScientist.onboard import run_onboard + from EvoScientist.config.onboard import run_onboard - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.load_config") as mock_load, \ - mock.patch("EvoScientist.onboard.save_config") as mock_save, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.load_config") as mock_load, \ + patch("EvoScientist.config.onboard.save_config") as mock_save, \ + patch("EvoScientist.config.onboard.console"): # Setup mock config mock_load.return_value = EvoScientistConfig() @@ -702,11 +702,11 @@ class TestRunOnboard: def test_returns_false_on_cancel(self): """Test that run_onboard returns False when cancelled.""" - from EvoScientist.onboard import run_onboard + from EvoScientist.config.onboard import run_onboard - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.load_config") as mock_load, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.load_config") as mock_load, \ + patch("EvoScientist.config.onboard.console"): mock_load.return_value = EvoScientistConfig() # First selection returns None (Ctrl+C) @@ -718,12 +718,12 @@ class TestRunOnboard: def test_returns_false_when_not_saving(self): """Test that run_onboard returns False when user declines to save.""" - from EvoScientist.onboard import run_onboard + from EvoScientist.config.onboard import run_onboard - with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ - mock.patch("EvoScientist.onboard.load_config") as mock_load, \ - mock.patch("EvoScientist.onboard.save_config") as mock_save, \ - mock.patch("EvoScientist.onboard.console"): + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.load_config") as mock_load, \ + patch("EvoScientist.config.onboard.save_config") as mock_save, \ + patch("EvoScientist.config.onboard.console"): mock_load.return_value = EvoScientistConfig() mock_q.select.return_value.ask.side_effect = [ diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index be54fb6..266d5e2 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -1,7 +1,7 @@ """Tests for EvoScientist.tools.skills_manager module.""" from pathlib import Path -from unittest import mock +from unittest.mock import patch import pytest @@ -272,7 +272,7 @@ class TestListSkills: """Tests for list_skills function.""" def test_list_empty_dir(self, temp_skills_dir): - with mock.patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): skills = list_skills(include_system=False) assert skills == [] @@ -280,7 +280,7 @@ class TestListSkills: # Install a skill install_skill(str(sample_skill_dir), str(temp_skills_dir)) - with mock.patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): skills = list_skills(include_system=False) assert len(skills) == 1 @@ -302,7 +302,7 @@ description: Skill number {i} ) install_skill(str(skill_dir), str(temp_skills_dir)) - with mock.patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): skills = list_skills(include_system=False) assert len(skills) == 3 @@ -324,7 +324,7 @@ class TestUninstallSkill: # Install first install_skill(str(sample_skill_dir), str(temp_skills_dir)) - with mock.patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): result = uninstall_skill("sample-skill") assert result["success"] is True @@ -334,7 +334,7 @@ class TestUninstallSkill: assert not skill_path.exists() def test_uninstall_nonexistent_skill(self, temp_skills_dir): - with mock.patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): result = uninstall_skill("nonexistent-skill") assert result["success"] is False diff --git a/tests/test_stream_state.py b/tests/test_stream_state.py index 6128621..c5e142d 100644 --- a/tests/test_stream_state.py +++ b/tests/test_stream_state.py @@ -8,7 +8,9 @@ from EvoScientist.cli import ( ) -# === SubAgentState === +# ============================================================================= +# SubAgentState +# ============================================================================= class TestSubAgentState: def test_add_tool_call(self): @@ -74,7 +76,9 @@ class TestSubAgentState: assert result is not None -# === StreamState === +# ============================================================================= +# StreamState +# ============================================================================= class TestStreamState: def test_handle_thinking(self): @@ -199,7 +203,9 @@ class TestStreamState: assert state.subagents[0].is_active is False -# === Name merging === +# ============================================================================= +# Name merging +# ============================================================================= class TestNameMerging: def test_generic_subagent_merged(self): @@ -259,7 +265,9 @@ class TestSubagentNameResolution: assert state.subagents[-1].name == "sub-agent" -# === _parse_todo_items === +# ============================================================================= +# _parse_todo_items +# ============================================================================= class TestParseTodoItems: def test_json_input(self): @@ -285,7 +293,9 @@ class TestParseTodoItems: assert _parse_todo_items("") is None -# === _build_todo_stats === +# ============================================================================= +# _build_todo_stats +# ============================================================================= class TestBuildTodoStats: def test_mixed_statuses(self): @@ -316,7 +326,9 @@ class TestBuildTodoStats: assert "0 items" in result -# === _resolve_subagent_name === +# ============================================================================= +# _resolve_subagent_name +# ============================================================================= class TestResolveSubagentName: def test_real_name_passthrough(self): @@ -341,7 +353,9 @@ class TestResolveSubagentName: assert state._resolve_subagent_name("sub-agent") == "sub-agent" -# === subagent_end fallback === +# ============================================================================= +# subagent_end fallback +# ============================================================================= class TestSubagentEndFallback: def test_end_resolves_via_name(self): @@ -369,7 +383,9 @@ class TestSubagentEndFallback: assert state.subagents[1].is_active is True # b-agent still active -# === Todo capture from write_todos args === +# ============================================================================= +# Todo capture from write_todos args +# ============================================================================= class TestTodoCaptureFromArgs: def test_capture_from_tool_call_args(self): @@ -442,7 +458,9 @@ class TestTodoCaptureFromArgs: assert state.todo_items == [] -# === latest_text reset on tool_call === +# ============================================================================= +# latest_text reset on tool_call +# ============================================================================= class TestLatestTextReset: def test_latest_text_accumulates(self): @@ -462,7 +480,9 @@ class TestLatestTextReset: assert state.response_text == "first segmentsecond segment" -# === Name merging edge cases === +# ============================================================================= +# Name merging edge cases +# ============================================================================= class TestNameMergingAdvanced: def test_subagent_merges_into_preregistered(self): @@ -511,7 +531,9 @@ class TestNameMergingAdvanced: assert research_sa.tool_calls[0]["name"] == "tavily_search" -# === _parse_todo_items edge cases === +# ============================================================================= +# _parse_todo_items edge cases +# ============================================================================= class TestParseTodoItemsAdvanced: def test_prefixed_with_update_message(self): @@ -531,7 +553,9 @@ class TestParseTodoItemsAdvanced: assert _parse_todo_items('["a", "b"]') is None -# === ChannelState queue mechanism === +# ============================================================================= +# ChannelState queue mechanism +# ============================================================================= class TestChannelState: """Tests for _ChannelState queue-based communication."""