diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 179f7cb..6c8b500 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -13,6 +13,7 @@ Usage: ... """ +import json from datetime import datetime from pathlib import Path @@ -27,12 +28,8 @@ from .middleware import create_memory_middleware, create_skills_middleware from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt from .utils import load_subagents from .tools import tavily_search, think_tool, skill_manager, view_image -from .paths import ( - default_workspace_dir, - set_active_workspace, - MEMORY_DIR as _MEMORY_DIR_PATH, - USER_SKILLS_DIR as _USER_SKILLS_DIR_PATH, -) +from . import paths as _paths_mod +from .paths import set_active_workspace, set_workspace_root # ============================================================================= # Configuration @@ -42,16 +39,25 @@ from .paths import ( _config = get_effective_config() apply_config_to_env(_config) +# NOTE: We intentionally do NOT call set_workspace_root() at module level. +# The CLI (commands.py) calls set_workspace_root() *before* importing this +# module. A module-level call here would overwrite the CLI's --workdir +# value with config.default_workdir, violating the priority chain +# (CLI args > config file). Instead, config.default_workdir is applied +# as a fallback inside create_cli_agent() when no explicit workspace_dir +# is provided. + # Research limits (from config) MAX_CONCURRENT = _config.max_concurrent MAX_ITERATIONS = _config.max_iterations # Workspace settings (defer dir creation to CLI; here we just resolve paths) -WORKSPACE_DIR = str(default_workspace_dir()) +# Read from the paths module so values reflect any earlier set_workspace_root(). +WORKSPACE_DIR = str(_paths_mod.WORKSPACE_ROOT) set_active_workspace(WORKSPACE_DIR) -MEMORY_DIR = str(_MEMORY_DIR_PATH) # Shared across sessions (not per-session) +MEMORY_DIR = str(_paths_mod.MEMORY_DIR) # Shared across sessions (not per-session) SKILLS_DIR = str(Path(__file__).parent / "skills") -USER_SKILLS_DIR = str(_USER_SKILLS_DIR_PATH) +USER_SKILLS_DIR = str(_paths_mod.USER_SKILLS_DIR) SUBAGENTS_CONFIG = Path(__file__).parent / "subagent.yaml" # ============================================================================= @@ -110,6 +116,44 @@ tool_registry = { # Base tools that every agent variant gets (before MCP) BASE_TOOLS = [think_tool, skill_manager, view_image] +# Cache MCP tools by the effective config signature to avoid reconnecting +# to MCP servers on every `/new` when config is unchanged. +_MCP_TOOLS_CACHE_KEY: str | None = None +_MCP_TOOLS_CACHE_VALUE: dict[str, list] | None = None + + +def _mcp_config_signature() -> str: + """Return a stable signature for the effective MCP config.""" + from .mcp.client import load_mcp_config + + cfg = load_mcp_config() + if not cfg: + return "" + try: + return json.dumps(cfg, sort_keys=True, ensure_ascii=True) + except TypeError: + # Fallback for non-JSON-serializable values (should be rare) + return repr(cfg) + + +def _load_mcp_tools_cached() -> dict[str, list]: + """Load MCP tools with config-aware caching.""" + global _MCP_TOOLS_CACHE_KEY, _MCP_TOOLS_CACHE_VALUE + + cfg_key = _mcp_config_signature() + if not cfg_key: + _MCP_TOOLS_CACHE_KEY = "" + _MCP_TOOLS_CACHE_VALUE = {} + return {} + + if _MCP_TOOLS_CACHE_KEY == cfg_key and _MCP_TOOLS_CACHE_VALUE is not None: + return {k: list(v) for k, v in _MCP_TOOLS_CACHE_VALUE.items()} + + loaded = load_mcp_tools() + _MCP_TOOLS_CACHE_KEY = cfg_key + _MCP_TOOLS_CACHE_VALUE = {k: list(v) for k, v in loaded.items()} + return {k: list(v) for k, v in loaded.items()} + def _build_base_kwargs(base_backend, base_middleware): """Build agent kwargs *without* MCP (fast, no subprocess spawning).""" @@ -131,12 +175,12 @@ def _build_base_kwargs(base_backend, base_middleware): def load_mcp_and_build_kwargs(base_backend, base_middleware): - """(Re-)load MCP tools and build agent kwargs. + """Load MCP tools (cached by config) and build agent kwargs. - Called on every ``create_cli_agent()`` call so that ``/new`` picks up - MCP config changes. Falls back to base kwargs if no MCP configured. + Re-connects to MCP servers only when the effective MCP config changes. + Falls back to base kwargs if no MCP configured. """ - mcp_by_agent = load_mcp_tools() + mcp_by_agent = _load_mcp_tools_cached() if not mcp_by_agent: return _build_base_kwargs(base_backend, base_middleware) @@ -205,45 +249,65 @@ def __getattr__(name: str): def create_cli_agent(workspace_dir: str | None = None, checkpointer=None): """Create agent with checkpointer for CLI multi-turn support. + A fresh backend is constructed on every call using the current + ``paths.WORKSPACE_ROOT`` (or the explicit *workspace_dir*), so + runtime ``set_workspace_root()`` changes are always respected. + Args: - workspace_dir: Optional per-session workspace directory. If provided, - creates a fresh backend rooted at this path. If None, uses the - module-level default backend (./workspace). - checkpointer: Optional LangGraph checkpointer. If None, falls back - to ``InMemorySaver`` (non-persistent). + workspace_dir: Per-session workspace directory. If ``None``, + defaults to the current ``paths.WORKSPACE_ROOT``. + checkpointer: Optional LangGraph checkpointer. If ``None``, + falls back to ``InMemorySaver`` (non-persistent). """ + import os as _os + from . import paths as _paths + if checkpointer is None: from langgraph.checkpoint.memory import InMemorySaver # type: ignore[import-untyped] checkpointer = InMemorySaver() - if workspace_dir: - set_active_workspace(workspace_dir) - ws_backend = CustomSandboxBackend( - root_dir=workspace_dir, - virtual_mode=True, - timeout=300, - ) - sk_backend = MergedReadOnlyBackend( - primary_dir=USER_SKILLS_DIR, - secondary_dir=SKILLS_DIR, - ) - # Memory always uses SHARED directory (not per-session) for cross-session persistence - mem_backend = FilesystemBackend( - root_dir=MEMORY_DIR, - virtual_mode=True, - ) - be = CompositeBackend( - default=ws_backend, - routes={ - "/skills/": sk_backend, - "/memory/": mem_backend, - }, - ) - else: - be = backend + # When no explicit workspace_dir is provided, apply config.default_workdir + # as a fallback. This covers direct callers (notebooks, iMessage server) + # that never call set_workspace_root() themselves. CLI callers always + # pass workspace_dir explicitly, so their --workdir is never overwritten. + if workspace_dir is None: + if _config.default_workdir: + set_workspace_root( + _os.path.abspath(_os.path.expanduser(_config.default_workdir)) + ) + workspace_dir = str(_paths.WORKSPACE_ROOT) + + # Read paths dynamically so runtime set_workspace_root() changes are picked up + _mem_dir = str(_paths.MEMORY_DIR) + _usr_skills_dir = str(_paths.USER_SKILLS_DIR) + + # Always construct fresh backends from current paths (avoids stale + # module-level backend when workspace root changed at runtime). + set_active_workspace(workspace_dir) + ws_backend = CustomSandboxBackend( + root_dir=workspace_dir, + virtual_mode=True, + timeout=300, + ) + sk_backend = MergedReadOnlyBackend( + primary_dir=_usr_skills_dir, + secondary_dir=SKILLS_DIR, + ) + # Memory always uses SHARED directory (not per-session) for cross-session persistence + mem_backend = FilesystemBackend( + root_dir=_mem_dir, + virtual_mode=True, + ) + be = CompositeBackend( + default=ws_backend, + routes={ + "/skills/": sk_backend, + "/memory/": mem_backend, + }, + ) mw = [ - create_memory_middleware(MEMORY_DIR, extraction_model=chat_model), + create_memory_middleware(_mem_dir, extraction_model=chat_model), ] # Re-load MCP tools from current config (picks up /mcp add changes) diff --git a/EvoScientist/cli/agent.py b/EvoScientist/cli/agent.py index 9029194..7362460 100644 --- a/EvoScientist/cli/agent.py +++ b/EvoScientist/cli/agent.py @@ -4,7 +4,7 @@ import os from datetime import datetime from pathlib import Path -from ..paths import new_run_dir, RUNS_DIR +from ..paths import new_run_dir def _shorten_path(path: str) -> str: @@ -21,8 +21,11 @@ def _shorten_path(path: str) -> str: return path -def _deduplicate_run_name(name: str, runs_dir: Path = RUNS_DIR) -> str: +def _deduplicate_run_name(name: str, runs_dir: Path | None = None) -> str: """Return *name* if available, otherwise *name_1*, *name_2*, etc.""" + if runs_dir is None: + from ..paths import RUNS_DIR + runs_dir = RUNS_DIR if not (runs_dir / name).exists(): return name i = 1 @@ -40,7 +43,8 @@ def _create_session_workspace(name: str | None = None) -> str: if *name* is None. """ if name: - session_id = _deduplicate_run_name(name) + from ..paths import RUNS_DIR + session_id = _deduplicate_run_name(name, RUNS_DIR) else: session_id = datetime.now().strftime("%Y%m%d_%H%M%S") workspace_dir = str(new_run_dir(session_id)) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 806f6d0..43c2624 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -11,7 +11,7 @@ import typer # type: ignore[import-untyped] from rich.table import Table from ..stream.display import console -from ..paths import ensure_dirs, default_workspace_dir +from ..paths import ensure_dirs, default_workspace_dir, set_workspace_root from ._app import app, config_app, mcp_app, channel_app from .agent import _deduplicate_run_name, _create_session_workspace, _load_agent, _shorten_path from .channel import _channels_stop, _start_channels_bus_mode @@ -456,16 +456,19 @@ def _main_callback( # --use-cwd is kept for backward compat but is now the default behavior if use_cwd: workspace_dir = os.getcwd() + set_workspace_root(workspace_dir) workspace_fixed = True elif workdir: workspace_dir = os.path.abspath(os.path.expanduser(workdir)) os.makedirs(workspace_dir, exist_ok=True) + set_workspace_root(workspace_dir) workspace_fixed = True elif mode: # Explicit --mode overrides default_workdir effective_mode = mode workspace_root = config.default_workdir or os.getcwd() workspace_root = os.path.abspath(os.path.expanduser(workspace_root)) + set_workspace_root(workspace_root) if effective_mode == "run": runs_dir = Path(workspace_root, "runs") session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S") @@ -478,6 +481,7 @@ def _main_callback( elif config.default_workdir: # Use configured default workdir with configured mode workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir)) + set_workspace_root(workspace_root) effective_mode = config.default_mode if effective_mode == "run": runs_dir = Path(workspace_root, "runs") @@ -490,11 +494,13 @@ def _main_callback( workspace_fixed = True else: effective_mode = config.default_mode + workspace_root = os.getcwd() + set_workspace_root(workspace_root) if effective_mode == "run": workspace_dir = _create_session_workspace(name) workspace_fixed = False else: # daemon mode (default) — use current directory - workspace_dir = os.getcwd() + workspace_dir = workspace_root workspace_fixed = True # Ensure memory and skills subdirs exist in workspace diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index 6c25376..a6c9adf 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -197,8 +197,8 @@ def cmd_interactive( import nest_asyncio nest_asyncio.apply() - from ..EvoScientist import MEMORY_DIR - memory_dir = MEMORY_DIR + from .. import paths + memory_dir = str(paths.MEMORY_DIR) history_file = str(os.path.expanduser("~/.EvoScientist_history")) session = PromptSession( diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index b6398b0..09608b8 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -429,7 +429,9 @@ def _prompt_and_validate_api_key( raise KeyboardInterrupt() new_key = new_key.strip() - key_to_validate = new_key if new_key else current + + # Determine which key to validate: new input or existing + key_to_validate = new_key or current if not key_to_validate: return None @@ -443,6 +445,7 @@ def _prompt_and_validate_api_key( else: console.print(f"\r [red]\u2717 {msg}[/red] ") if not new_key: + # Existing key is invalid — warn but keep (user didn't change it) return None save_anyway = questionary.confirm( "Save anyway?", @@ -671,8 +674,12 @@ def _step_workspace(config: EvoScientistConfig) -> tuple[str, str]: # Custom workdir (optional) current_default = config.default_workdir or "" + if current_default: + prompt_text = f"Workspace directory (Enter to keep '{current_default}'):" + else: + prompt_text = f"Workspace directory (Enter to use ./{cwd_short}/):" workdir = questionary.text( - f"Workspace directory (Enter to use ./{cwd_short}/):", + prompt_text, default=current_default, style=WIZARD_STYLE, qmark=QMARK, diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 443e6f6..134ef5c 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -52,7 +52,7 @@ class EvoScientistConfig: provider: Default LLM provider ('anthropic', 'openai', 'google-genai', or 'nvidia'). model: Default model name (short name or full ID). default_mode: Default workspace mode ('daemon' or 'run'). - default_workdir: Default workspace directory (empty = use ./workspace). + default_workdir: Default workspace directory (empty = use current working directory). max_concurrent: Maximum concurrent sub-agents. max_iterations: Maximum delegation iterations. show_thinking: Whether to show thinking panels in CLI. diff --git a/EvoScientist/paths.py b/EvoScientist/paths.py index b5e3c4b..fa16f34 100644 --- a/EvoScientist/paths.py +++ b/EvoScientist/paths.py @@ -26,6 +26,21 @@ MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory") USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills") +def set_workspace_root(path: str | Path) -> None: + """Update workspace root and re-derive dependent directories. + + Directories with an explicit environment-variable override keep their + env-var value; all others are re-derived from the new root. + Also resets ``_active_workspace`` to the new root as a safe default. + """ + global WORKSPACE_ROOT, RUNS_DIR, MEMORY_DIR, USER_SKILLS_DIR, _active_workspace + WORKSPACE_ROOT = Path(path).resolve() + _active_workspace = WORKSPACE_ROOT + RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs") + MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory") + USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills") + + def ensure_dirs() -> None: """Create runtime subdirectories (memory, skills) if they do not exist. diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py index c380c87..8f7ab43 100644 --- a/EvoScientist/tools/skills_manager.py +++ b/EvoScientist/tools/skills_manager.py @@ -1,7 +1,7 @@ """Skill installation and management for EvoScientist. This module provides functions for installing, listing, and uninstalling user skills. -Skills are installed to USER_SKILLS_DIR (./workspace/skills/). +Skills are installed to USER_SKILLS_DIR (defaults to /skills/). Supported installation sources: - Local directory paths @@ -37,7 +37,7 @@ from pathlib import Path import yaml -from ..paths import USER_SKILLS_DIR +from .. import paths @dataclass @@ -220,7 +220,7 @@ def install_skill(source: str, dest_dir: str | None = None) -> dict: - path: installed path (if successful) - error: error message (if failed) """ - dest_dir = dest_dir or str(USER_SKILLS_DIR) + dest_dir = dest_dir or str(paths.USER_SKILLS_DIR) os.makedirs(dest_dir, exist_ok=True) if _is_github_url(source): @@ -370,7 +370,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]: skills: list[SkillInfo] = [] # User skills - user_dir = Path(USER_SKILLS_DIR) + user_dir = Path(paths.USER_SKILLS_DIR) if user_dir.exists(): for entry in sorted(user_dir.iterdir()): if entry.is_dir() and _validate_skill_dir(entry): @@ -421,7 +421,7 @@ def uninstall_skill(name: str) -> dict: - success: bool - error: error message (if failed) """ - user_dir = Path(USER_SKILLS_DIR).resolve() + user_dir = Path(paths.USER_SKILLS_DIR).resolve() # Validate name to prevent path traversal clean_name = _sanitize_name(name) diff --git a/README.md b/README.md index 5c46d90..e003373 100644 --- a/README.md +++ b/README.md @@ -239,25 +239,27 @@ The channel can also be started manually with `/channel` in the interactive CLI. ### Runtime Directories -By default, the **workspace** is created under the current directory: +By default, the **workspace root** is the current working directory. Sub-directories +are created automatically: ``` -./workspace/ +/ memory/ # shared MEMORY.md (persistent across sessions) skills/ # user-installed skills - runs/ # per-session workspaces + runs/ # per-session workspaces (run mode only) ``` -You can force workspace to be the current directory via `--use-cwd`. +Use `--workdir` to set a different workspace root, or configure it via +`EvoSci config set default_workdir /path/to/workspace`. Override individual paths via environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `EVOSCIENTIST_WORKSPACE_DIR` | `./workspace` | Root workspace directory | -| `EVOSCIENTIST_RUNS_DIR` | `./workspace/runs` | Per-session run directories | -| `EVOSCIENTIST_MEMORY_DIR` | `./workspace/memory` | Shared memory storage | -| `EVOSCIENTIST_SKILLS_DIR` | `./workspace/skills` | User-installed skills | +| `EVOSCIENTIST_WORKSPACE_DIR` | current directory | Root workspace directory | +| `EVOSCIENTIST_RUNS_DIR` | `/runs` | Per-session run directories | +| `EVOSCIENTIST_MEMORY_DIR` | `/memory` | Shared memory storage | +| `EVOSCIENTIST_SKILLS_DIR` | `/skills` | User-installed skills | ### Script Inference ```python diff --git a/tests/test_agent_mcp_cache.py b/tests/test_agent_mcp_cache.py new file mode 100644 index 0000000..a029092 --- /dev/null +++ b/tests/test_agent_mcp_cache.py @@ -0,0 +1,59 @@ +"""Tests for MCP tool caching in EvoScientist.EvoScientist.""" + +from __future__ import annotations + +import EvoScientist.EvoScientist as agent_module + + +def _reset_mcp_cache() -> None: + agent_module._MCP_TOOLS_CACHE_KEY = None + agent_module._MCP_TOOLS_CACHE_VALUE = None + + +class TestMcpToolCaching: + def setup_method(self) -> None: + _reset_mcp_cache() + + def test_reuses_cached_tools_when_config_unchanged(self, monkeypatch): + calls = {"load": 0} + tool = object() + + monkeypatch.setattr( + "EvoScientist.mcp.client.load_mcp_config", + lambda: {"srv": {"transport": "stdio", "command": "demo"}}, + ) + + def fake_load_mcp_tools(): + calls["load"] += 1 + return {"main": [tool]} + + monkeypatch.setattr(agent_module, "load_mcp_tools", fake_load_mcp_tools) + + first = agent_module._load_mcp_tools_cached() + second = agent_module._load_mcp_tools_cached() + + assert calls["load"] == 1 + assert first == second + assert first is not second + assert first["main"] is not second["main"] + + def test_reload_when_config_changes(self, monkeypatch): + calls = {"load": 0} + state = {"cfg": {"srv": {"transport": "stdio", "command": "v1"}}} + + def fake_load_config(): + return state["cfg"] + + def fake_load_mcp_tools(): + calls["load"] += 1 + return {"main": [f"tool-v{calls['load']}"]} + + monkeypatch.setattr("EvoScientist.mcp.client.load_mcp_config", fake_load_config) + monkeypatch.setattr(agent_module, "load_mcp_tools", fake_load_mcp_tools) + + first = agent_module._load_mcp_tools_cached() + state["cfg"] = {"srv": {"transport": "stdio", "command": "v2"}} + second = agent_module._load_mcp_tools_cached() + + assert calls["load"] == 2 + assert first != second diff --git a/tests/test_onboard.py b/tests/test_onboard.py index d222eb7..5f34380 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -283,6 +283,45 @@ class TestStepWorkspace: assert result == ("run", "/custom/path") +class TestPromptAndValidateApiKey: + def test_keep_existing_key_still_validates(self): + """Pressing Enter to keep current key should validate the existing key.""" + from EvoScientist.config.onboard import _prompt_and_validate_api_key + + validate_fn = Mock(return_value=(True, "Valid")) + + with patch("EvoScientist.config.onboard.questionary") as mock_q, \ + patch("EvoScientist.config.onboard.console"): + mock_q.password.return_value.ask.return_value = "" # keep existing + result = _prompt_and_validate_api_key( + "Enter key:", + current="existing-key", + validate_fn=validate_fn, + skip_validation=False, + ) + + assert result is None # None means "keep existing, don't overwrite" + validate_fn.assert_called_once_with("existing-key") + + def test_new_key_still_validates(self): + """Entering a new key should still run validation.""" + from EvoScientist.config.onboard import _prompt_and_validate_api_key + + validate_fn = Mock(return_value=(True, "valid")) + + with patch("EvoScientist.config.onboard.questionary") as mock_q: + mock_q.password.return_value.ask.return_value = "new-key" + result = _prompt_and_validate_api_key( + "Enter key:", + current="old-key", + validate_fn=validate_fn, + skip_validation=False, + ) + + assert result == "new-key" + validate_fn.assert_called_once_with("new-key") + + class TestValidateImessage: def test_valid_when_cli_found_with_rpc(self): """Test validate_imessage returns valid when imsg CLI found and RPC works.""" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..d20929f --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,105 @@ +"""Tests for EvoScientist.paths — set_workspace_root and ensure_dirs.""" + +import os +from unittest import mock + +import pytest + +from EvoScientist import paths + + +@pytest.fixture(autouse=True) +def _restore_paths(): + """Snapshot module-level path globals and restore after each test.""" + orig = { + "WORKSPACE_ROOT": paths.WORKSPACE_ROOT, + "RUNS_DIR": paths.RUNS_DIR, + "MEMORY_DIR": paths.MEMORY_DIR, + "USER_SKILLS_DIR": paths.USER_SKILLS_DIR, + "_active_workspace": paths._active_workspace, + } + yield + paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"] + paths.RUNS_DIR = orig["RUNS_DIR"] + paths.MEMORY_DIR = orig["MEMORY_DIR"] + paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"] + paths._active_workspace = orig["_active_workspace"] + + +class TestSetWorkspaceRoot: + """Tests for set_workspace_root().""" + + def test_updates_derived_dirs(self, tmp_path): + """set_workspace_root should update WORKSPACE_ROOT and all derived dirs.""" + new_root = tmp_path / "my_workspace" + new_root.mkdir() + + paths.set_workspace_root(new_root) + + assert paths.WORKSPACE_ROOT == new_root.resolve() + assert paths.RUNS_DIR == new_root.resolve() / "runs" + assert paths.MEMORY_DIR == new_root.resolve() / "memory" + assert paths.USER_SKILLS_DIR == new_root.resolve() / "skills" + + def test_resets_active_workspace(self, tmp_path): + """set_workspace_root should reset _active_workspace to new root.""" + new_root = tmp_path / "ws" + new_root.mkdir() + + # Set active workspace to something different first + paths._active_workspace = tmp_path / "other" + + paths.set_workspace_root(new_root) + + assert paths._active_workspace == new_root.resolve() + + def test_preserves_env_overrides(self, tmp_path): + """Dirs set via env vars should NOT be overwritten by set_workspace_root.""" + custom_mem = tmp_path / "custom_memory" + custom_skills = tmp_path / "custom_skills" + custom_runs = tmp_path / "custom_runs" + + env = { + "EVOSCIENTIST_MEMORY_DIR": str(custom_mem), + "EVOSCIENTIST_SKILLS_DIR": str(custom_skills), + "EVOSCIENTIST_RUNS_DIR": str(custom_runs), + } + + new_root = tmp_path / "ws" + new_root.mkdir() + + with mock.patch.dict(os.environ, env): + paths.set_workspace_root(new_root) + + # WORKSPACE_ROOT and _active_workspace should still update + assert paths.WORKSPACE_ROOT == new_root.resolve() + assert paths._active_workspace == new_root.resolve() + + # Derived dirs should reflect the env overrides, not the new root + assert paths.MEMORY_DIR == custom_mem.expanduser() + assert paths.USER_SKILLS_DIR == custom_skills.expanduser() + assert paths.RUNS_DIR == custom_runs.expanduser() + + def test_accepts_string_path(self, tmp_path): + """set_workspace_root should accept str as well as Path.""" + new_root = tmp_path / "str_ws" + new_root.mkdir() + + paths.set_workspace_root(str(new_root)) + + assert paths.WORKSPACE_ROOT == new_root.resolve() + + +class TestEnsureDirsUsesUpdatedPaths: + """ensure_dirs should create dirs at the currently set paths.""" + + def test_ensure_dirs_uses_updated_paths(self, tmp_path): + """After set_workspace_root, ensure_dirs creates dirs at new location.""" + new_root = tmp_path / "workspace" + new_root.mkdir() + + paths.set_workspace_root(new_root) + paths.ensure_dirs() + + assert (new_root / "memory").is_dir() + assert (new_root / "skills").is_dir() diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index 266d5e2..bdf2a2d 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -272,7 +272,7 @@ class TestListSkills: """Tests for list_skills function.""" def test_list_empty_dir(self, temp_skills_dir): - with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.paths.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 patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.paths.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 patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.paths.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 patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.paths.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 patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir): + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): result = uninstall_skill("nonexistent-skill") assert result["success"] is False