Merge remote-tracking branch 'origin/main' into feature/channel-unification

# Conflicts:
#	EvoScientist/EvoScientist.py
#	EvoScientist/channels/imessage/serve.py
#	EvoScientist/cli/commands.py
This commit is contained in:
MuXinCG
2026-02-15 20:37:12 +08:00
13 changed files with 373 additions and 72 deletions
+108 -44
View File
@@ -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)
+7 -3
View File
@@ -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))
+8 -2
View File
@@ -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
+2 -2
View File
@@ -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(
+9 -2
View File
@@ -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,
+1 -1
View File
@@ -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.
+15
View File
@@ -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.
+5 -5
View File
@@ -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 <workspace>/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)
+10 -8
View File
@@ -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/
<cwd>/
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` | `<workspace>/runs` | Per-session run directories |
| `EVOSCIENTIST_MEMORY_DIR` | `<workspace>/memory` | Shared memory storage |
| `EVOSCIENTIST_SKILLS_DIR` | `<workspace>/skills` | User-installed skills |
### Script Inference
```python
+59
View File
@@ -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
+39
View File
@@ -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."""
+105
View File
@@ -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()
+5 -5
View File
@@ -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