feat(memory): migrate MEMORY.md to global path & enhance ask-user prompts (#161)
* feat(prompt): enhance user interaction with multiple-choice and free-text questions * refactor(paths): rename MEMORY_DIR to MEMORIES_DIR for consistency * style(tests): format code for better readability in test cases * feat(prompt): add validation for 'other' option in user prompt * feat(prompt): refactor validation logic for user prompts and add skip option * feat(style): refactor to use shared _PICKER_STYLE from interactive module
This commit is contained in:
@@ -257,7 +257,7 @@ def _get_default_backend():
|
||||
|
||||
workspace_dir = str(_paths_mod.WORKSPACE_ROOT)
|
||||
set_active_workspace(workspace_dir)
|
||||
memory_dir = str(_paths_mod.MEMORY_DIR)
|
||||
memory_dir = str(_paths_mod.MEMORIES_DIR)
|
||||
user_skills_dir = str(_paths_mod.USER_SKILLS_DIR)
|
||||
global_skills_dir = str(_paths_mod.GLOBAL_SKILLS_DIR)
|
||||
|
||||
@@ -279,7 +279,7 @@ def _get_default_backend():
|
||||
default=ws_backend,
|
||||
routes={
|
||||
"/skills/": sk_backend,
|
||||
"/memory/": mem_backend,
|
||||
"/memories/": mem_backend,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -296,7 +296,7 @@ def _get_default_middleware():
|
||||
|
||||
cfg = _ensure_config()
|
||||
model = _ensure_chat_model()
|
||||
memory_dir = str(_paths_mod.MEMORY_DIR)
|
||||
memory_dir = str(_paths_mod.MEMORIES_DIR)
|
||||
mw = [
|
||||
create_context_editing_middleware(model),
|
||||
ContextOverflowMapperMiddleware(),
|
||||
@@ -395,7 +395,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
|
||||
workspace_dir = str(_paths.WORKSPACE_ROOT)
|
||||
|
||||
# Read paths dynamically so runtime set_workspace_root() changes are picked up
|
||||
_mem_dir = str(_paths.MEMORY_DIR)
|
||||
_mem_dir = str(_paths.MEMORIES_DIR)
|
||||
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
|
||||
_global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR)
|
||||
|
||||
@@ -412,7 +412,6 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
|
||||
global_dir=_global_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,
|
||||
@@ -421,7 +420,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
|
||||
default=ws_backend,
|
||||
routes={
|
||||
"/skills/": sk_backend,
|
||||
"/memory/": mem_backend,
|
||||
"/memories/": mem_backend,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ def cmd_interactive(
|
||||
|
||||
from .. import paths
|
||||
|
||||
memory_dir = str(paths.MEMORY_DIR)
|
||||
memory_dir = str(paths.MEMORIES_DIR)
|
||||
|
||||
from ..config.settings import get_config_dir
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
from collections import Counter
|
||||
|
||||
import questionary
|
||||
from prompt_toolkit.styles import Style as PtStyle
|
||||
from questionary import Choice
|
||||
|
||||
from ..mcp.registry import (
|
||||
@@ -22,17 +21,7 @@ from ..mcp.registry import (
|
||||
install_mcp_servers,
|
||||
)
|
||||
from ..stream.display import console
|
||||
|
||||
_PICKER_STYLE = PtStyle.from_dict(
|
||||
{
|
||||
"questionmark": "#888888",
|
||||
"question": "",
|
||||
"pointer": "bold",
|
||||
"highlighted": "bold",
|
||||
"text": "#888888",
|
||||
"answer": "bold",
|
||||
}
|
||||
)
|
||||
from .interactive import _PICKER_STYLE
|
||||
|
||||
_INSTALLED_INDICATOR = ("fg:#4caf50", "\u2713 ")
|
||||
|
||||
|
||||
@@ -150,22 +150,11 @@ def _cmd_install_skills(args: str = "") -> None:
|
||||
from collections import Counter
|
||||
|
||||
import questionary
|
||||
from prompt_toolkit.styles import Style as PtStyle
|
||||
from questionary import Choice
|
||||
|
||||
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
from ..tools.skills_manager import fetch_remote_skill_index, install_skill
|
||||
|
||||
_PICKER_STYLE = PtStyle.from_dict(
|
||||
{
|
||||
"questionmark": "#888888",
|
||||
"question": "",
|
||||
"pointer": "bold",
|
||||
"highlighted": "bold",
|
||||
"text": "#888888",
|
||||
"answer": "bold",
|
||||
}
|
||||
)
|
||||
from .interactive import _PICKER_STYLE
|
||||
|
||||
# Installed-item indicator style for disabled checkbox choices.
|
||||
_INSTALLED_INDICATOR = ("fg:#4caf50", "✓ ")
|
||||
|
||||
@@ -49,7 +49,7 @@ class CurrentCommand(Command):
|
||||
f"Workspace: {_shorten_path(ctx.workspace_dir)}",
|
||||
style="dim",
|
||||
)
|
||||
memory_path = paths.MEMORY_DIR
|
||||
memory_path = paths.MEMORIES_DIR
|
||||
if memory_path:
|
||||
from ...cli.agent import _shorten_path
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Automatically extracts and persists long-term memory (user profile, research
|
||||
preferences, experiment conclusions) from conversations.
|
||||
|
||||
Two mechanisms:
|
||||
1. **Injection** (every LLM call): Reads ``/memory/MEMORY.md`` and appends it
|
||||
1. **Injection** (every LLM call): Reads ``/memories/MEMORY.md`` and appends it
|
||||
to the system prompt so the agent always has context.
|
||||
2. **Extraction** (threshold-triggered): When the conversation exceeds a
|
||||
configurable message count, uses an LLM call to pull out structured facts
|
||||
@@ -17,7 +17,7 @@ from EvoScientist.middleware import EvoMemoryMiddleware
|
||||
|
||||
middleware = EvoMemoryMiddleware(
|
||||
backend=my_backend, # or backend factory
|
||||
memory_path="/memory/MEMORY.md",
|
||||
memory_path="/memories/MEMORY.md",
|
||||
extraction_model=chat_model,
|
||||
trigger=("messages", 20),
|
||||
)
|
||||
@@ -168,7 +168,7 @@ Use this to personalize your responses and avoid re-asking known information.
|
||||
- An experiment completes with notable conclusions
|
||||
|
||||
**How to update memory:**
|
||||
- If `/memory/MEMORY.md` does not exist yet, use `write_file` to create it
|
||||
- If `/memories/MEMORY.md` does not exist yet, use `write_file` to create it
|
||||
- If it already exists, use `edit_file` to update specific sections
|
||||
- Use this markdown structure:
|
||||
|
||||
@@ -446,7 +446,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
|
||||
|
||||
Args:
|
||||
backend: Backend instance or factory for reading/writing memory files.
|
||||
memory_path: Virtual path to MEMORY.md (default ``/memory/MEMORY.md``).
|
||||
memory_path: Virtual path to MEMORY.md (default ``/memories/MEMORY.md``).
|
||||
extraction_model: Chat model used for extraction (can be a cheap/fast
|
||||
model like ``claude-haiku``). If ``None``, automatic extraction is
|
||||
disabled and only prompt injection + manual ``edit_file`` works.
|
||||
@@ -461,7 +461,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
|
||||
self,
|
||||
*,
|
||||
backend: BACKEND_TYPES,
|
||||
memory_path: str = "/memory/MEMORY.md",
|
||||
memory_path: str = "/memories/MEMORY.md",
|
||||
extraction_model: BaseChatModel | None = None,
|
||||
trigger: tuple[str, int] = ("messages", 20),
|
||||
) -> None:
|
||||
@@ -687,7 +687,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
|
||||
logger.debug("Failed to load memory during modify_request: %s", e)
|
||||
# Use placeholder when memory file doesn't exist yet
|
||||
if not memory_content:
|
||||
memory_content = "(No memory saved yet. Create `/memory/MEMORY.md` when you learn important information.)"
|
||||
memory_content = "(No memory saved yet. Create `/memories/MEMORY.md` when you learn important information.)"
|
||||
|
||||
from deepagents.middleware._utils import append_to_system_message
|
||||
|
||||
@@ -802,7 +802,7 @@ def create_memory_middleware(
|
||||
"""
|
||||
from deepagents.backends import FilesystemBackend
|
||||
|
||||
from ..paths import MEMORY_DIR as _DEFAULT_MEMORY_DIR
|
||||
from ..paths import MEMORIES_DIR as _DEFAULT_MEMORY_DIR
|
||||
|
||||
if memory_dir is None:
|
||||
memory_dir = str(_DEFAULT_MEMORY_DIR)
|
||||
|
||||
+30
-4
@@ -22,7 +22,6 @@ def _env_path(key: str) -> Path | None:
|
||||
WORKSPACE_ROOT = _env_path("EVOSCIENTIST_WORKSPACE_DIR") or Path.cwd()
|
||||
|
||||
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")
|
||||
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
|
||||
|
||||
@@ -33,9 +32,27 @@ def _global_skills_dir() -> Path:
|
||||
return base / "evoscientist" / "skills"
|
||||
|
||||
|
||||
def _global_memories_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||
return base / "evoscientist" / "memories"
|
||||
|
||||
|
||||
# Global skills: shared across all workspaces (~/.config/evoscientist/skills/)
|
||||
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
|
||||
|
||||
# Global memories: shared across all workspaces (~/.config/evoscientist/memories/)
|
||||
GLOBAL_MEMORIES_DIR: Path = _global_memories_dir()
|
||||
|
||||
# Memories dir: global by default, overridable via env var.
|
||||
# Supports both new (EVOSCIENTIST_MEMORIES_DIR) and old (EVOSCIENTIST_MEMORY_DIR) env vars.
|
||||
MEMORIES_DIR: Path = (
|
||||
_env_path("EVOSCIENTIST_MEMORIES_DIR")
|
||||
or _env_path("EVOSCIENTIST_MEMORY_DIR")
|
||||
or GLOBAL_MEMORIES_DIR
|
||||
)
|
||||
MEMORY_DIR = MEMORIES_DIR # backward compat alias
|
||||
|
||||
|
||||
def set_workspace_root(path: str | Path) -> None:
|
||||
"""Update workspace root and re-derive dependent directories.
|
||||
@@ -43,10 +60,14 @@ def set_workspace_root(path: str | Path) -> None:
|
||||
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.
|
||||
|
||||
Note: MEMORIES_DIR is global (not workspace-scoped) but env var overrides
|
||||
are re-evaluated here to support late-set environment variables.
|
||||
"""
|
||||
global \
|
||||
WORKSPACE_ROOT, \
|
||||
RUNS_DIR, \
|
||||
MEMORIES_DIR, \
|
||||
MEMORY_DIR, \
|
||||
USER_SKILLS_DIR, \
|
||||
MEDIA_DIR, \
|
||||
@@ -54,7 +75,12 @@ def set_workspace_root(path: str | Path) -> None:
|
||||
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")
|
||||
MEMORIES_DIR = (
|
||||
_env_path("EVOSCIENTIST_MEMORIES_DIR")
|
||||
or _env_path("EVOSCIENTIST_MEMORY_DIR")
|
||||
or GLOBAL_MEMORIES_DIR
|
||||
)
|
||||
MEMORY_DIR = MEMORIES_DIR
|
||||
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (
|
||||
WORKSPACE_ROOT / "skills"
|
||||
)
|
||||
@@ -64,13 +90,13 @@ def set_workspace_root(path: str | Path) -> None:
|
||||
def ensure_dirs() -> None:
|
||||
"""Create runtime subdirectories if they do not exist.
|
||||
|
||||
Only memory is created eagerly — skills directories are created on demand
|
||||
Only memories is created eagerly — skills directories are created on demand
|
||||
by install_skill() when the user first installs a skill.
|
||||
|
||||
Does NOT create the workspace root itself — it should already exist
|
||||
(either the user's cwd or a directory they specified).
|
||||
"""
|
||||
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MEMORIES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def default_workspace_dir() -> Path:
|
||||
|
||||
@@ -987,16 +987,20 @@ def _get_event_loop() -> asyncio.AbstractEventLoop:
|
||||
def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
|
||||
"""Interactive console Q&A for ask_user events.
|
||||
|
||||
Presents questions via ``prompt_toolkit.prompt()`` (not ``input()``)
|
||||
for proper CJK IME support and styled prompts without cursor drift.
|
||||
Presents multiple-choice questions with arrow-key navigation via
|
||||
``questionary.select()`` and free-text questions via
|
||||
``questionary.text()`` with required-field validation. Matches the
|
||||
questionary style used throughout the rest of the CLI.
|
||||
"""
|
||||
from prompt_toolkit import prompt as pt_prompt # type: ignore[import-untyped]
|
||||
from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped]
|
||||
import questionary # type: ignore[import-untyped]
|
||||
|
||||
from ..cli.interactive import _PICKER_STYLE
|
||||
|
||||
questions = ask_user_data.get("questions", [])
|
||||
if not questions:
|
||||
return {"answers": [], "status": "answered"}
|
||||
|
||||
total = len(questions)
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
@@ -1013,43 +1017,65 @@ def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
|
||||
q_text = q.get("question", "")
|
||||
q_type = q.get("type", "text")
|
||||
required = q.get("required", True)
|
||||
tag = " [dim](optional)[/dim]" if not required else ""
|
||||
console.print(f" [bold]{i + 1}. {q_text}[/bold]{tag}")
|
||||
optional_suffix = " (optional)" if not required else ""
|
||||
prompt_text = f"({i + 1}/{total}) {q_text}{optional_suffix}"
|
||||
|
||||
def _make_validator(is_required: bool):
|
||||
def _validate(v: str) -> bool | str:
|
||||
if is_required and not v.strip():
|
||||
return "This field is required."
|
||||
return True
|
||||
|
||||
return _validate
|
||||
|
||||
if q_type == "multiple_choice":
|
||||
choices = q.get("choices", [])
|
||||
for j, choice in enumerate(choices):
|
||||
label = choice.get("value", str(choice))
|
||||
letter = chr(ord("A") + j)
|
||||
console.print(Text(f" {letter}. {label}", style="dim"))
|
||||
other_letter = chr(ord("A") + len(choices))
|
||||
console.print(
|
||||
Text(f" {other_letter}. Other (type your answer)", style="dim")
|
||||
)
|
||||
choice_labels = [c.get("value", str(c)) for c in choices]
|
||||
skip_label = "Skip"
|
||||
if not required:
|
||||
choice_labels.append(skip_label)
|
||||
other_label = "Other (type your answer)"
|
||||
choice_labels.append(other_label)
|
||||
|
||||
selected = questionary.select(
|
||||
prompt_text,
|
||||
choices=choice_labels,
|
||||
style=_PICKER_STYLE,
|
||||
).ask()
|
||||
|
||||
if selected is None: # Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if selected == skip_label:
|
||||
answers.append("")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
if selected == other_label:
|
||||
selected = questionary.text(
|
||||
"Your answer:",
|
||||
validate=_make_validator(required),
|
||||
style=_PICKER_STYLE,
|
||||
).ask()
|
||||
if selected is None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
answers.append(selected)
|
||||
|
||||
letters = "/".join(chr(ord("A") + k) for k in range(len(choices) + 1))
|
||||
raw = pt_prompt(
|
||||
HTML(f" <b><style fg='#1565c0'>Choice [{letters}]:</style></b> ")
|
||||
).strip()
|
||||
if raw.upper() == other_letter:
|
||||
raw = pt_prompt(
|
||||
HTML(" <b><style fg='#42a5f5'>> Your answer:</style></b> ")
|
||||
).strip()
|
||||
answers.append(raw)
|
||||
elif len(raw) == 1 and raw.upper().isalpha():
|
||||
idx = ord(raw.upper()) - ord("A")
|
||||
if 0 <= idx < len(choices):
|
||||
answers.append(choices[idx].get("value", raw))
|
||||
else:
|
||||
answers.append(raw)
|
||||
else:
|
||||
answers.append(raw)
|
||||
else:
|
||||
raw = pt_prompt(
|
||||
HTML(" <b><style fg='#42a5f5'>> Answer:</style></b> ")
|
||||
).strip()
|
||||
answers.append(raw)
|
||||
answer = questionary.text(
|
||||
prompt_text,
|
||||
validate=_make_validator(required),
|
||||
style=_PICKER_STYLE,
|
||||
).ask()
|
||||
|
||||
if answer is None: # Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
|
||||
answers.append(answer)
|
||||
|
||||
console.print()
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
console.print("[dim] Cancelled.[/dim]")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
@@ -114,9 +114,9 @@ def _tool_path_arg(args: dict | None) -> str:
|
||||
|
||||
|
||||
def _is_memory_path(path: str) -> bool:
|
||||
"""Return True when a virtual path targets the shared memory directory."""
|
||||
"""Return True when a virtual path targets the global memories directory."""
|
||||
normalized = (path or "").strip()
|
||||
return normalized == "/memory" or normalized.startswith("/memory/")
|
||||
return normalized == "/memories" or normalized.startswith("/memories/")
|
||||
|
||||
|
||||
def format_tool_compact(name: str, args: dict | None) -> str:
|
||||
@@ -250,7 +250,7 @@ def format_tool_compact_with_result(
|
||||
|
||||
if name_lower in ("write_file", "edit_file"):
|
||||
if (
|
||||
"/memory/" in result_content
|
||||
"/memories/" in result_content
|
||||
or "/MEMORY.md" in result_content
|
||||
or "MEMORY.md" in result_content
|
||||
):
|
||||
|
||||
+45
-7
@@ -445,30 +445,37 @@ def test_auto_mode_disables_ask_user_middleware(
|
||||
|
||||
|
||||
class TestRichCLIPrompt:
|
||||
"""Test _resolve_ask_user_prompt with mocked prompt_toolkit.prompt()."""
|
||||
|
||||
_PT_PROMPT = "prompt_toolkit.prompt"
|
||||
"""Test _resolve_ask_user_prompt with mocked questionary."""
|
||||
|
||||
def test_text_question_returns_answered(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from EvoScientist.stream.display import _resolve_ask_user_prompt
|
||||
|
||||
data = {
|
||||
"questions": [{"question": "What dataset?", "type": "text"}],
|
||||
"tool_call_id": "tc_1",
|
||||
}
|
||||
with patch(self._PT_PROMPT, side_effect=["CIFAR-10"]):
|
||||
mock_text = MagicMock()
|
||||
mock_text.return_value.ask.return_value = "CIFAR-10"
|
||||
with patch("questionary.text", mock_text):
|
||||
result = _resolve_ask_user_prompt(data)
|
||||
assert result["status"] == "answered"
|
||||
assert result["answers"] == ["CIFAR-10"]
|
||||
|
||||
def test_keyboard_interrupt_returns_cancelled(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from EvoScientist.stream.display import _resolve_ask_user_prompt
|
||||
|
||||
data = {
|
||||
"questions": [{"question": "What?", "type": "text"}],
|
||||
"tool_call_id": "tc_1",
|
||||
}
|
||||
with patch(self._PT_PROMPT, side_effect=KeyboardInterrupt):
|
||||
# questionary returns None when user presses Ctrl+C
|
||||
mock_text = MagicMock()
|
||||
mock_text.return_value.ask.return_value = None
|
||||
with patch("questionary.text", mock_text):
|
||||
result = _resolve_ask_user_prompt(data)
|
||||
assert result["status"] == "cancelled"
|
||||
|
||||
@@ -480,7 +487,9 @@ class TestRichCLIPrompt:
|
||||
assert result["status"] == "answered"
|
||||
assert result["answers"] == []
|
||||
|
||||
def test_multiple_choice_letter_mapping(self):
|
||||
def test_multiple_choice_selection(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from EvoScientist.stream.display import _resolve_ask_user_prompt
|
||||
|
||||
data = {
|
||||
@@ -493,11 +502,40 @@ class TestRichCLIPrompt:
|
||||
],
|
||||
"tool_call_id": "tc_1",
|
||||
}
|
||||
with patch(self._PT_PROMPT, side_effect=["B"]):
|
||||
mock_select = MagicMock()
|
||||
mock_select.return_value.ask.return_value = "ImageNet"
|
||||
with patch("questionary.select", mock_select):
|
||||
result = _resolve_ask_user_prompt(data)
|
||||
assert result["status"] == "answered"
|
||||
assert result["answers"] == ["ImageNet"]
|
||||
|
||||
def test_multiple_choice_other_option(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from EvoScientist.stream.display import _resolve_ask_user_prompt
|
||||
|
||||
data = {
|
||||
"questions": [
|
||||
{
|
||||
"question": "Which?",
|
||||
"type": "multiple_choice",
|
||||
"choices": [{"value": "CIFAR-10"}, {"value": "ImageNet"}],
|
||||
}
|
||||
],
|
||||
"tool_call_id": "tc_1",
|
||||
}
|
||||
mock_select = MagicMock()
|
||||
mock_select.return_value.ask.return_value = "Other (type your answer)"
|
||||
mock_text = MagicMock()
|
||||
mock_text.return_value.ask.return_value = "custom dataset"
|
||||
with (
|
||||
patch("questionary.select", mock_select),
|
||||
patch("questionary.text", mock_text),
|
||||
):
|
||||
result = _resolve_ask_user_prompt(data)
|
||||
assert result["status"] == "answered"
|
||||
assert result["answers"] == ["custom dataset"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TUI widget (basic construction)
|
||||
|
||||
+27
-12
@@ -14,6 +14,7 @@ def _restore_paths():
|
||||
orig = {
|
||||
"WORKSPACE_ROOT": paths.WORKSPACE_ROOT,
|
||||
"RUNS_DIR": paths.RUNS_DIR,
|
||||
"MEMORIES_DIR": paths.MEMORIES_DIR,
|
||||
"MEMORY_DIR": paths.MEMORY_DIR,
|
||||
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
|
||||
"_active_workspace": paths._active_workspace,
|
||||
@@ -21,6 +22,7 @@ def _restore_paths():
|
||||
yield
|
||||
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
|
||||
paths.RUNS_DIR = orig["RUNS_DIR"]
|
||||
paths.MEMORIES_DIR = orig["MEMORIES_DIR"]
|
||||
paths.MEMORY_DIR = orig["MEMORY_DIR"]
|
||||
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
|
||||
paths._active_workspace = orig["_active_workspace"]
|
||||
@@ -29,8 +31,13 @@ def _restore_paths():
|
||||
class TestSetWorkspaceRoot:
|
||||
"""Tests for set_workspace_root()."""
|
||||
|
||||
def test_updates_derived_dirs(self, tmp_path):
|
||||
def test_updates_derived_dirs(self, tmp_path, monkeypatch):
|
||||
"""set_workspace_root should update WORKSPACE_ROOT and all derived dirs."""
|
||||
monkeypatch.delenv("EVOSCIENTIST_MEMORIES_DIR", raising=False)
|
||||
monkeypatch.delenv("EVOSCIENTIST_MEMORY_DIR", raising=False)
|
||||
monkeypatch.delenv("EVOSCIENTIST_RUNS_DIR", raising=False)
|
||||
monkeypatch.delenv("EVOSCIENTIST_SKILLS_DIR", raising=False)
|
||||
|
||||
new_root = tmp_path / "my_workspace"
|
||||
new_root.mkdir()
|
||||
|
||||
@@ -38,7 +45,8 @@ class TestSetWorkspaceRoot:
|
||||
|
||||
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
||||
assert paths.RUNS_DIR == new_root.resolve() / "runs"
|
||||
assert paths.MEMORY_DIR == new_root.resolve() / "memory"
|
||||
# MEMORIES_DIR is global — not derived from workspace root
|
||||
assert paths.MEMORIES_DIR == paths.GLOBAL_MEMORIES_DIR
|
||||
assert paths.USER_SKILLS_DIR == new_root.resolve() / "skills"
|
||||
|
||||
def test_resets_active_workspace(self, tmp_path):
|
||||
@@ -60,7 +68,7 @@ class TestSetWorkspaceRoot:
|
||||
custom_runs = tmp_path / "custom_runs"
|
||||
|
||||
env = {
|
||||
"EVOSCIENTIST_MEMORY_DIR": str(custom_mem),
|
||||
"EVOSCIENTIST_MEMORIES_DIR": str(custom_mem),
|
||||
"EVOSCIENTIST_SKILLS_DIR": str(custom_skills),
|
||||
"EVOSCIENTIST_RUNS_DIR": str(custom_runs),
|
||||
}
|
||||
@@ -75,8 +83,8 @@ class TestSetWorkspaceRoot:
|
||||
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()
|
||||
# MEMORIES_DIR respects env override
|
||||
assert paths.MEMORIES_DIR == custom_mem.expanduser()
|
||||
assert paths.USER_SKILLS_DIR == custom_skills.expanduser()
|
||||
assert paths.RUNS_DIR == custom_runs.expanduser()
|
||||
|
||||
@@ -94,14 +102,21 @@ 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."""
|
||||
"""ensure_dirs creates the global memories dir (not workspace-local)."""
|
||||
new_root = tmp_path / "workspace"
|
||||
new_root.mkdir()
|
||||
|
||||
paths.set_workspace_root(new_root)
|
||||
paths.ensure_dirs()
|
||||
custom_mem = tmp_path / "memories"
|
||||
|
||||
assert (new_root / "memory").is_dir()
|
||||
assert not (
|
||||
new_root / "skills"
|
||||
).exists() # skills created on demand by install_skill()
|
||||
with mock.patch.dict(
|
||||
os.environ, {"EVOSCIENTIST_MEMORIES_DIR": str(custom_mem)}
|
||||
):
|
||||
paths.set_workspace_root(new_root)
|
||||
paths.ensure_dirs()
|
||||
|
||||
assert custom_mem.is_dir()
|
||||
assert not (new_root / "memory").exists() # no longer workspace-local
|
||||
assert not (new_root / "memories").exists()
|
||||
assert not (
|
||||
new_root / "skills"
|
||||
).exists() # skills created on demand by install_skill()
|
||||
|
||||
+38
-17
@@ -95,38 +95,51 @@ class TestFormatToolCompact:
|
||||
result = format_tool_compact("edit_file", {"path": "f.py"})
|
||||
assert result == "edit_file(f.py)"
|
||||
|
||||
# Memory file special display
|
||||
def test_read_file_memory(self):
|
||||
result = format_tool_compact("read_file", {"path": "/memory/MEMORY.md"})
|
||||
# Global memory file special display (/memories/ = global)
|
||||
def test_read_file_global_memory(self):
|
||||
result = format_tool_compact("read_file", {"path": "/memories/MEMORY.md"})
|
||||
assert result == "Reading memory"
|
||||
|
||||
def test_read_file_memory_file_path_alias(self):
|
||||
result = format_tool_compact("read_file", {"file_path": "/memory/MEMORY.md"})
|
||||
def test_read_file_global_memory_file_path_alias(self):
|
||||
result = format_tool_compact("read_file", {"file_path": "/memories/MEMORY.md"})
|
||||
assert result == "Reading memory"
|
||||
|
||||
def test_read_file_any_memory_file(self):
|
||||
result = format_tool_compact("read_file", {"path": "/memory/history.md"})
|
||||
def test_read_file_any_global_memory_file(self):
|
||||
result = format_tool_compact("read_file", {"path": "/memories/history.md"})
|
||||
assert result == "Reading memory"
|
||||
|
||||
def test_write_file_memory(self):
|
||||
def test_write_file_global_memory(self):
|
||||
result = format_tool_compact("write_file", {"path": "/MEMORY.md"})
|
||||
assert result == "Updating memory"
|
||||
# Also covers paths with /memory/ prefix
|
||||
result2 = format_tool_compact("write_file", {"path": "/memory/MEMORY.md"})
|
||||
# Also covers paths with /memories/ prefix
|
||||
result2 = format_tool_compact("write_file", {"path": "/memories/MEMORY.md"})
|
||||
assert result2 == "Updating memory"
|
||||
|
||||
def test_edit_file_memory(self):
|
||||
result = format_tool_compact("edit_file", {"path": "/memory/MEMORY.md"})
|
||||
def test_edit_file_global_memory(self):
|
||||
result = format_tool_compact("edit_file", {"path": "/memories/MEMORY.md"})
|
||||
assert result == "Updating memory"
|
||||
|
||||
def test_write_edit_any_memory_file(self):
|
||||
write_result = format_tool_compact("write_file", {"path": "/memory/soul.md"})
|
||||
def test_write_edit_any_global_memory_file(self):
|
||||
write_result = format_tool_compact("write_file", {"path": "/memories/soul.md"})
|
||||
edit_result = format_tool_compact(
|
||||
"edit_file", {"path": "/memory/skills-context.md"}
|
||||
"edit_file", {"path": "/memories/skills-context.md"}
|
||||
)
|
||||
assert write_result == "Updating memory"
|
||||
assert edit_result == "Updating memory"
|
||||
|
||||
# Project-local /memory/ files show normal tool display
|
||||
def test_read_file_project_memory(self):
|
||||
result = format_tool_compact(
|
||||
"read_file", {"path": "/memory/ideation-memory.md"}
|
||||
)
|
||||
assert result == "read_file(/memory/ideation-memory.md)"
|
||||
|
||||
def test_edit_file_project_memory(self):
|
||||
result = format_tool_compact(
|
||||
"edit_file", {"path": "/memory/experiment-memory.md"}
|
||||
)
|
||||
assert result == "edit_file(/memory/experiment-memory.md)"
|
||||
|
||||
def test_memory_display_inferred_from_result_when_args_sparse(self):
|
||||
read_result = format_tool_compact_with_result(
|
||||
"read_file",
|
||||
@@ -138,17 +151,25 @@ class TestFormatToolCompact:
|
||||
edit_result = format_tool_compact_with_result(
|
||||
"edit_file",
|
||||
{},
|
||||
"Successfully replaced 1 instance(s) of the string in '/memory/MEMORY.md'",
|
||||
"Successfully replaced 1 instance(s) of the string in '/memories/MEMORY.md'",
|
||||
)
|
||||
assert edit_result == "Updating memory"
|
||||
|
||||
write_result = format_tool_compact_with_result(
|
||||
"write_file",
|
||||
{},
|
||||
"Wrote updated content to '/memory/history.md'",
|
||||
"Wrote updated content to '/memories/history.md'",
|
||||
)
|
||||
assert write_result == "Updating memory"
|
||||
|
||||
def test_project_memory_result_not_special(self):
|
||||
result = format_tool_compact_with_result(
|
||||
"write_file",
|
||||
{},
|
||||
"Wrote updated content to '/memory/ideation-memory.md'",
|
||||
)
|
||||
assert result != "Updating memory"
|
||||
|
||||
def test_glob(self):
|
||||
result = format_tool_compact("glob", {"pattern": "*.py"})
|
||||
assert result == "glob(*.py)"
|
||||
|
||||
@@ -206,7 +206,7 @@ class TestToolCallWidget(unittest.TestCase):
|
||||
|
||||
w = ToolCallWidget("edit_file", {}, "mem-1")
|
||||
w._result_content = (
|
||||
"Successfully replaced 1 instance(s) of the string in '/memory/MEMORY.md'"
|
||||
"Successfully replaced 1 instance(s) of the string in '/memories/MEMORY.md'"
|
||||
)
|
||||
|
||||
class _Header:
|
||||
|
||||
Reference in New Issue
Block a user