Files
EvoScientist/tests/test_autoskills.py
T
2026-07-03 11:16:33 +02:00

992 lines
34 KiB
Python

from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from EvoScientist import paths
from EvoScientist.config import (
EvoScientistConfig,
MemorySkillSynthesisCadence,
MemorySkillSynthesisMode,
save_config,
set_config_value,
)
from EvoScientist.memory.autoskills.candidates import autoskill_candidates
from EvoScientist.memory.autoskills.proposals import (
approve_skill_proposal,
autoskill_proposals_dir,
list_skill_proposals,
pending_skill_proposal_count,
reject_skill_proposal,
submit_autoskill_proposal,
)
from EvoScientist.memory.autoskills.schedule import (
AUTOSKILL_GRAPH_ID,
AUTOSKILL_RUN_KIND,
AUTOSKILL_SCHEDULE_SEARCH_LIMIT,
alist_autoskill_schedules,
autoskill_cron,
reconcile_autoskill_schedule,
)
from EvoScientist.memory.autoskills.tools import create_submit_autoskill_proposal_tool
from EvoScientist.memory.observations import (
MemoryScope,
MemorySourceType,
MemoryType,
ObservationRelation,
link_observation_files,
record_observation_file,
)
def _record(
memory_dir,
*,
summary: str,
observation: str,
memory_type: MemoryType = MemoryType.PROCEDURAL,
):
return record_observation_file(
memory_dir=memory_dir,
project_id="P-project",
memory_type=memory_type,
summary=summary,
observation=observation,
why_it_matters=f"Future agents can reuse this pattern: {summary}",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
def _write_skill_folder(memory_dir, skill_name: str, description: str, body: str):
proposal_dir = autoskill_proposals_dir(memory_dir) / skill_name
proposal_dir.mkdir(parents=True, exist_ok=True)
(proposal_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return proposal_dir
def _write_installed_skill(root, skill_name: str, description: str, body: str):
skill_dir = root / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return skill_dir
def _write_installed_skill_in_dir(
root,
directory_name: str,
*,
skill_name: str,
description: str,
body: str,
):
skill_dir = root / directory_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: {description}\n---\n\n{body}\n",
encoding="utf-8",
)
return skill_dir
def test_autoskill_cron_uses_presets():
assert autoskill_cron("nightly", "03:00") == "0 3 * * *"
assert autoskill_cron("weekly", "04:30") == "30 4 * * 0"
assert autoskill_cron("monthly", "22:05") == "5 22 1 * *"
def test_autoskill_candidates_use_linked_procedural_clusters(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use focused pytest before full suite.",
observation="Run the focused pytest file before the full test suite.",
)
second = _record(
memory_dir,
summary="Use ruff on changed Python modules.",
observation="Run ruff on changed modules before broad validation.",
)
third = _record(
memory_dir,
summary="Validation workflow benefits from narrow checks.",
observation="Narrow validation catches regressions before expensive checks.",
memory_type=MemoryType.SEMANTIC,
)
for source, target in ((first, second), (second, third)):
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
reason="These observations describe the same validation workflow.",
)
candidates = autoskill_candidates(
memory_dir=memory_dir,
project_id="P-project",
)
assert len(candidates) == 1
assert set(candidates[0]["observation_ids"]) == {
first["observation_id"],
second["observation_id"],
third["observation_id"],
}
assert candidates[0]["procedural_count"] == 2
assert candidates[0]["semantic_count"] == 1
assert candidates[0]["episodic_count"] == 0
assert candidates[0]["existing_pending_proposal"] is False
assert candidates[0]["already_processed"] is False
def test_autoskill_candidates_surface_contradiction_clusters(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use cached package metadata for offline installs.",
observation="Cached package metadata works when the network is unavailable.",
)
second = _record(
memory_dir,
summary="Avoid cached metadata for editable dependency changes.",
observation="Cached package metadata can hide editable dependency changes.",
)
third = _record(
memory_dir,
summary="Package validation should check cache freshness.",
observation="Validation should distinguish offline cache use from stale cache risks.",
memory_type=MemoryType.SEMANTIC,
)
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
relation=ObservationRelation.CONTRADICTS,
reason="Cached metadata helps offline installs but can hide editable changes.",
)
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=second["observation_id"],
target_observation_id=third["observation_id"],
reason="Both observations describe cache-aware package validation.",
)
candidates = autoskill_candidates(
memory_dir=memory_dir,
project_id="P-project",
)
assert len(candidates) == 1
assert set(candidates[0]["observation_ids"]) == {
first["observation_id"],
second["observation_id"],
third["observation_id"],
}
assert any(
relation["relation"] == ObservationRelation.CONTRADICTS
for relation in candidates[0]["relations"]
)
def test_autoskill_candidate_hash_ignores_mutable_relations(tmp_path):
memory_dir = tmp_path / "memories"
first = _record(
memory_dir,
summary="Use focused pytest before full suite.",
observation="Run the focused pytest file before the full test suite.",
)
second = _record(
memory_dir,
summary="Use ruff on changed Python modules.",
observation="Run ruff on changed modules before broad validation.",
)
third = _record(
memory_dir,
summary="Validation workflow benefits from narrow checks.",
observation="Narrow validation catches regressions before expensive checks.",
memory_type=MemoryType.SEMANTIC,
)
for source, target in ((first, second), (second, third)):
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
reason="These observations describe the same validation workflow.",
)
before = autoskill_candidates(memory_dir=memory_dir, project_id="P-project")[0]
link_observation_files(
memory_dir=memory_dir,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=third["observation_id"],
reason="A later linker pass found another relation in the same cluster.",
)
after = autoskill_candidates(memory_dir=memory_dir, project_id="P-project")[0]
assert after["cluster_hash"] == before["cluster_hash"]
assert after["observation_ids"] == before["observation_ids"]
assert len(after["relations"]) > len(before["relations"])
def test_skill_proposal_lifecycle_promotes_to_workspace_skill(tmp_path):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nRun narrow checks before broad ones.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Three observations describe the same staged validation practice.",
)
assert proposal["submitted"] is True
assert pending_skill_proposal_count(memory_dir) == 1
pending = list_skill_proposals(memory_dir, status="pending")
assert pending[0].skill_name == "focused-validation"
assert pending[0].proposal_id == "focused-validation"
approved = approve_skill_proposal(
memory_dir,
pending[0].proposal_id,
skills_dir=skills_dir,
)
assert approved["approved"] is True
skill_md = skills_dir / "focused-validation" / "SKILL.md"
assert skill_md.exists()
assert "name: focused-validation" in skill_md.read_text(encoding="utf-8")
assert pending_skill_proposal_count(memory_dir) == 0
assert list_skill_proposals(memory_dir)[0].status == "approved"
def test_update_skill_proposal_replaces_workspace_skill(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_installed_skill(
skills_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nOld workflow.",
)
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks and caveats.",
"# Focused validation\n\nUpdated workflow with caveats.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="New observations refine the existing validation skill.",
operation="update",
target_skill_name="focused-validation",
)
pending = list_skill_proposals(memory_dir, status="pending")
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
skill_md = skills_dir / "focused-validation" / "SKILL.md"
saved = skill_md.read_text(encoding="utf-8")
assert proposal["submitted"] is True
assert proposal["operation"] == "update"
assert pending[0].operation == "update"
assert pending[0].target_skill_name == "focused-validation"
assert approved["approved"] is True
assert approved["operation"] == "update"
assert "Updated workflow with caveats." in saved
assert "Old workflow." not in saved
assert list_skill_proposals(memory_dir)[0].status == "approved"
def test_update_skill_proposal_preserves_existing_workspace_files(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
skill_dir = _write_installed_skill(
skills_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nOld workflow.",
)
script_path = skill_dir / "scripts" / "run.sh"
script_path.parent.mkdir(parents=True)
script_path.write_text("#!/bin/sh\necho validate\n", encoding="utf-8")
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks and caveats.",
"# Focused validation\n\nUpdated workflow with caveats.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="New observations refine the existing validation skill.",
operation="update",
target_skill_name="focused-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert "Updated workflow with caveats." in (skill_dir / "SKILL.md").read_text(
encoding="utf-8"
)
assert script_path.read_text(encoding="utf-8") == "#!/bin/sh\necho validate\n"
def test_update_can_reopen_completed_autoskill_proposal(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"reopen-update",
"Use when testing completed autoskill proposals.",
"# Reopen update\n\nInitial workflow.",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reopen-update",
cluster_hash="cluster-initial",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
approved = approve_skill_proposal(memory_dir, first["proposal_id"])
_write_skill_folder(
memory_dir,
"reopen-update",
"Use when testing completed autoskill proposal updates.",
"# Reopen update\n\nUpdated workflow.",
)
reopened = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reopen-update",
cluster_hash="cluster-update",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="reopen-update",
)
proposal = list_skill_proposals(memory_dir, status="pending")[0]
assert approved["approved"] is True
assert reopened["submitted"] is True
assert reopened["created"] is False
assert proposal.operation == "update"
assert proposal.cluster_hash == "cluster-update"
def test_update_cannot_reopen_rejected_autoskill_proposal(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nInitial workflow.",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-update",
cluster_hash="cluster-initial",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
rejected = reject_skill_proposal(memory_dir, first["proposal_id"])
_write_installed_skill(
skills_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nInstalled workflow.",
)
_write_skill_folder(
memory_dir,
"reject-update",
"Use when testing rejected autoskill proposal updates.",
"# Reject update\n\nUpdated workflow.",
)
reopened = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-update",
cluster_hash="cluster-update",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="reject-update",
)
assert rejected["rejected"] is True
assert reopened["submitted"] is False
assert reopened["status"] == "rejected"
assert list_skill_proposals(memory_dir)[0].status == "rejected"
def test_update_replaces_workspace_skill_matched_by_frontmatter(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
skills_dir = tmp_path / "skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", skills_dir)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
installed_dir = _write_installed_skill_in_dir(
skills_dir,
"legacy-directory",
skill_name="frontmatter-match",
description="Use when testing frontmatter skill matching.",
body="# Frontmatter match\n\nOld workflow.",
)
_write_skill_folder(
memory_dir,
"frontmatter-match",
"Use when testing frontmatter skill update matching.",
"# Frontmatter match\n\nUpdated workflow.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="frontmatter-match",
cluster_hash="cluster-frontmatter",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Later observations refine the existing skill.",
operation="update",
target_skill_name="frontmatter-match",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert approved["path"] == str(installed_dir)
assert "Updated workflow." in (installed_dir / "SKILL.md").read_text(
encoding="utf-8"
)
assert not (skills_dir / "frontmatter-match").exists()
def test_update_skill_proposal_requires_existing_skill(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", tmp_path / "skills")
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", tmp_path / "global-skills")
_write_skill_folder(
memory_dir,
"missing-target",
"Use when testing missing autoskill update targets.",
"# Missing target\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="missing-target",
cluster_hash="cluster-missing",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="This should not submit without an installed target.",
operation="update",
target_skill_name="missing-target",
)
assert proposal["submitted"] is False
assert "No installed workspace/global skill" in proposal["error"]
assert pending_skill_proposal_count(memory_dir) == 0
def test_update_global_skill_creates_workspace_shadow(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
workspace_skills = tmp_path / "workspace-skills"
global_skills = tmp_path / "global-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", workspace_skills)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", global_skills)
_write_installed_skill(
global_skills,
"global-validation",
"Use when validating code changes from a global skill.",
"# Global validation\n\nGlobal workflow.",
)
global_script = global_skills / "global-validation" / "scripts" / "run.sh"
global_script.parent.mkdir(parents=True)
global_script.write_text("#!/bin/sh\necho global\n", encoding="utf-8")
_write_skill_folder(
memory_dir,
"global-validation",
"Use when validating code changes from an updated global skill.",
"# Global validation\n\nWorkspace shadow update.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="global-validation",
cluster_hash="cluster-global-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Observations refine a global skill for this workspace.",
operation="update",
target_skill_name="global-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert approved["approved"] is True
assert approved["operation"] == "update"
assert (workspace_skills / "global-validation" / "SKILL.md").exists()
assert "Workspace shadow update." in (
workspace_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
assert (workspace_skills / "global-validation" / "scripts" / "run.sh").read_text(
encoding="utf-8"
) == "#!/bin/sh\necho global\n"
assert "Global workflow." in (
global_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
def test_update_global_skill_does_not_overwrite_nonmatching_local_dir(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
workspace_skills = tmp_path / "workspace-skills"
global_skills = tmp_path / "global-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", workspace_skills)
monkeypatch.setattr(paths, "GLOBAL_SKILLS_DIR", global_skills)
_write_installed_skill_in_dir(
workspace_skills,
"global-validation",
skill_name="different-local-skill",
description="Use when testing nonmatching local skill collisions.",
body="# Different local skill\n\nKeep this local content.",
)
_write_installed_skill(
global_skills,
"global-validation",
"Use when validating code changes from a global skill.",
"# Global validation\n\nGlobal workflow.",
)
_write_skill_folder(
memory_dir,
"global-validation",
"Use when validating code changes from an updated global skill.",
"# Global validation\n\nWorkspace shadow update.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="global-validation",
cluster_hash="cluster-global-update",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Observations refine a global skill for this workspace.",
operation="update",
target_skill_name="global-validation",
)
approved = approve_skill_proposal(memory_dir, proposal["proposal_id"])
assert proposal["submitted"] is True
assert approved["approved"] is False
assert "does not match" in approved["error"]
assert "Keep this local content." in (
workspace_skills / "global-validation" / "SKILL.md"
).read_text(encoding="utf-8")
def test_create_proposal_rejects_target_skill_name(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"target-on-create",
"Use when testing create proposal target validation.",
"# Target on create\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="target-on-create",
cluster_hash="cluster-target",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Create proposals should not name an update target.",
target_skill_name="target-on-create",
)
assert proposal["submitted"] is False
assert "only valid for update" in proposal["error"]
assert pending_skill_proposal_count(memory_dir) == 0
def test_submit_autoskill_proposal_defaults_missing_created_at(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"timestamp-default",
"Use when testing autoskill proposal timestamp defaults.",
"# Timestamp default\n",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="timestamp-default",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Initial proposal.",
)
manifest_path = (
autoskill_proposals_dir(memory_dir) / "timestamp-default" / "manifest.json"
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest.pop("created_at")
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
second = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="timestamp-default",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Resubmitted proposal.",
)
saved = json.loads(manifest_path.read_text(encoding="utf-8"))
proposal = list_skill_proposals(memory_dir)[0]
assert first["submitted"] is True
assert second["submitted"] is True
assert saved["created_at"] != "None"
assert saved["created_at"].endswith("Z")
assert proposal.created_at == saved["created_at"]
def test_approve_skill_proposal_is_scoped_to_recorded_workspace(
tmp_path,
monkeypatch,
):
memory_dir = tmp_path / "memories"
workspace_a = tmp_path / "workspace-a"
workspace_b = tmp_path / "workspace-b"
active_skills_dir = tmp_path / "active-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", active_skills_dir)
workspace_a.mkdir()
workspace_b.mkdir()
_write_skill_folder(
memory_dir,
"workspace-owned",
"Use when validating workspace ownership for autoskills.",
"# Workspace owned\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="workspace-owned",
cluster_hash="cluster-workspace-a",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="This proposal belongs to workspace A.",
workspace_dir=workspace_a,
project_id="P-a",
)
assert proposal["submitted"] is True
assert list_skill_proposals(memory_dir, workspace_dir=workspace_b) == []
wrong_workspace = approve_skill_proposal(
memory_dir,
proposal["proposal_id"],
workspace_dir=workspace_b,
)
right_workspace = approve_skill_proposal(
memory_dir,
proposal["proposal_id"],
workspace_dir=workspace_a,
)
assert wrong_workspace["approved"] is False
assert not (workspace_b / "skills" / "workspace-owned").exists()
assert right_workspace["approved"] is True
assert (active_skills_dir / "workspace-owned" / "SKILL.md").exists()
def test_submit_autoskill_proposal_does_not_overwrite_other_workspace(tmp_path):
memory_dir = tmp_path / "memories"
workspace_a = tmp_path / "workspace-a"
workspace_b = tmp_path / "workspace-b"
workspace_a.mkdir()
workspace_b.mkdir()
_write_skill_folder(
memory_dir,
"shared-name",
"Use when testing cross-workspace proposal name collisions.",
"# Shared name\n",
)
first = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="shared-name",
cluster_hash="cluster-a",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Workspace A owns this pending proposal.",
workspace_dir=workspace_a,
project_id="P-a",
)
second = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="shared-name",
cluster_hash="cluster-b",
source_observation_ids=["O-4", "O-5", "O-6"],
rationale="Workspace B must not take over the same proposal id.",
workspace_dir=workspace_b,
project_id="P-b",
)
assert first["submitted"] is True
assert second["submitted"] is False
assert "another workspace" in second["error"]
assert list_skill_proposals(memory_dir, workspace_dir=workspace_b) == []
def test_submit_tool_reads_live_autoskill_mode_without_rebuild(
tmp_path,
monkeypatch,
):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config-home"))
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *a, **k: str(tmp_path / ".env"),
)
monkeypatch.delenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", raising=False)
save_config(
EvoScientistConfig(
memory_skill_synthesis_mode=MemorySkillSynthesisMode.REVIEW,
)
)
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
active_skills_dir = tmp_path / "active-skills"
monkeypatch.setattr(paths, "USER_SKILLS_DIR", active_skills_dir)
workspace_dir.mkdir()
tool = create_submit_autoskill_proposal_tool(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id="P-project",
)
_write_skill_folder(
memory_dir,
"review-mode-skill",
"Use when testing review mode autoskill submissions.",
"# Review mode skill\n",
)
review_payload = json.loads(
tool.run(
{
"skill_name": "review-mode-skill",
"cluster_hash": "cluster-review",
"source_observation_ids": ["O-1", "O-2", "O-3"],
"rationale": "Review mode should only stage this proposal.",
}
)
)
set_config_value("memory_skill_synthesis_mode", "auto")
_write_skill_folder(
memory_dir,
"auto-mode-skill",
"Use when testing auto mode autoskill submissions.",
"# Auto mode skill\n",
)
auto_payload = json.loads(
tool.run(
{
"skill_name": "auto-mode-skill",
"cluster_hash": "cluster-auto",
"source_observation_ids": ["O-4", "O-5", "O-6"],
"rationale": "Auto mode should promote this proposal.",
}
)
)
assert review_payload["status"] == "pending"
assert "auto_approval" not in review_payload
assert auto_payload["auto_approval"]["approved"] is True
assert (active_skills_dir / "auto-mode-skill" / "SKILL.md").exists()
def test_submit_autoskill_proposal_rejects_invalid_generated_folder(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"focused-validation",
"Use when validating code changes with staged checks.",
"# Focused validation\n\nTODO: fill this in.",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="focused-validation",
cluster_hash="cluster-1",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Three observations describe the same staged validation practice.",
)
assert proposal["submitted"] is False
assert proposal["path"] == "/autoskill-proposals/focused-validation"
assert "TODO placeholders" in proposal["errors"][0]
assert pending_skill_proposal_count(memory_dir) == 0
def test_reject_skill_proposal_marks_processed(tmp_path):
memory_dir = tmp_path / "memories"
_write_skill_folder(
memory_dir,
"reject-me",
"Use when testing rejected proposals.",
"# Reject me\n",
)
proposal = submit_autoskill_proposal(
memory_dir=memory_dir,
skill_name="reject-me",
cluster_hash="cluster-rejected",
source_observation_ids=["O-1", "O-2", "O-3"],
rationale="Test rejection.",
)
rejected = reject_skill_proposal(memory_dir, proposal["proposal_id"])
assert rejected["rejected"] is True
assert list_skill_proposals(memory_dir)[0].status == "rejected"
assert (memory_dir / "autoskills" / "processed" / "cluster-rejected.json").exists()
class _FakeCrons:
def __init__(self):
self.rows: list[dict] = []
self.created: list[dict] = []
self.deleted: list[str] = []
self.searches: list[dict] = []
def search(self, **kwargs):
self.searches.append(kwargs)
return list(self.rows)
def create(self, **kwargs):
row = {
"cron_id": f"cron-{len(self.rows) + 1}",
"assistant_id": kwargs["assistant_id"],
"schedule": kwargs["schedule"],
"input": kwargs["input"],
"metadata": kwargs["metadata"],
"timezone": kwargs["timezone"],
"enabled": True,
}
self.rows.append(row)
self.created.append(row)
return row
def delete(self, cron_id: str):
self.deleted.append(cron_id)
self.rows = [row for row in self.rows if row["cron_id"] != cron_id]
class _AsyncFakeCrons:
def __init__(self):
self.searches: list[dict] = []
async def search(self, **kwargs):
self.searches.append(kwargs)
return [{"cron_id": "cron-async"}]
def test_alist_autoskill_schedules_uses_async_client_and_explicit_limit(monkeypatch):
crons = _AsyncFakeCrons()
client = SimpleNamespace(crons=crons)
monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: client)
rows = asyncio.run(
alist_autoskill_schedules(
EvoScientistConfig(),
limit=3,
)
)
assert rows == [{"cron_id": "cron-async"}]
assert crons.searches == [
{
"metadata": {"run_kind": AUTOSKILL_RUN_KIND},
"limit": 3,
}
]
def test_reconcile_autoskill_schedule_creates_updates_and_disables(
tmp_path,
monkeypatch,
):
crons = _FakeCrons()
client = SimpleNamespace(crons=crons)
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: True,
)
monkeypatch.setattr("langgraph_sdk.get_sync_client", lambda **_kwargs: client)
cfg = EvoScientistConfig(
memory_skill_synthesis_enabled=True,
memory_skill_synthesis_cadence=MemorySkillSynthesisCadence.WEEKLY,
memory_skill_synthesis_time="03:00",
scheduler_default_timezone="UTC",
)
created = reconcile_autoskill_schedule(cfg, workspace_dir=tmp_path)
unchanged = reconcile_autoskill_schedule(cfg, workspace_dir=tmp_path)
updated = reconcile_autoskill_schedule(
EvoScientistConfig(
memory_skill_synthesis_enabled=True,
memory_skill_synthesis_cadence=MemorySkillSynthesisCadence.NIGHTLY,
memory_skill_synthesis_time="03:00",
scheduler_default_timezone="UTC",
),
workspace_dir=tmp_path,
)
disabled = reconcile_autoskill_schedule(
EvoScientistConfig(memory_skill_synthesis_enabled=False),
workspace_dir=tmp_path,
)
assert created["status"] == "created"
assert unchanged["status"] == "unchanged"
assert updated["status"] == "created"
assert disabled == {"status": "disabled", "deleted": 1}
assert crons.deleted == ["cron-1", "cron-1"]
assert crons.rows == []
assert created["schedule"] == "0 3 * * 0"
assert updated["schedule"] == "0 3 * * *"
assert created["cron_id"] == "cron-1"
assert all(
search["limit"] == AUTOSKILL_SCHEDULE_SEARCH_LIMIT for search in crons.searches
)
assert [row["assistant_id"] for row in crons.created] == [
AUTOSKILL_GRAPH_ID,
AUTOSKILL_GRAPH_ID,
]