mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-22 23:05:37 +00:00
Keep profile catalog reads lightweight by detecting sparse editor overrides without building removal plans for every agent. Use the chat snapshot refresh as the single switch trigger and share concurrent same-context catalog requests while preserving newest-context-wins behavior.
1289 lines
43 KiB
Python
1289 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from io import BytesIO
|
|
import json
|
|
from pathlib import Path
|
|
import stat
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from werkzeug.datastructures import FileStorage
|
|
|
|
from helpers import yaml as yaml_helper
|
|
from plugins._agent_editor.api.agent_editor import AgentEditor
|
|
from plugins._agent_editor.api.agent_editor import _context as editor_context
|
|
from plugins._agent_editor.api.agent_editor_avatar import AgentEditorAvatar
|
|
from plugins._agent_editor.helpers import editor
|
|
|
|
|
|
@pytest.fixture
|
|
def user_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
root = tmp_path / "usr" / "agents"
|
|
real_determine_path = editor.plugins.determine_plugin_asset_path
|
|
|
|
def determine_plugin_asset_path(
|
|
plugin_name: str,
|
|
project_name: str,
|
|
profile_id: str,
|
|
*parts: str,
|
|
) -> str:
|
|
if profile_id and not project_name:
|
|
return str(
|
|
root
|
|
/ profile_id
|
|
/ editor.files.PLUGINS_DIR
|
|
/ plugin_name
|
|
/ Path(*parts)
|
|
)
|
|
return real_determine_path(plugin_name, project_name, profile_id, *parts)
|
|
|
|
monkeypatch.setattr(editor, "USER_AGENTS_ROOT", root)
|
|
monkeypatch.setattr(editor, "STAGED_AVATAR_ROOT", tmp_path / "staged")
|
|
monkeypatch.setattr(
|
|
editor.plugins,
|
|
"determine_plugin_asset_path",
|
|
determine_plugin_asset_path,
|
|
)
|
|
monkeypatch.setattr(editor.plugins, "clear_plugin_cache", lambda _names: None)
|
|
return root
|
|
|
|
|
|
@pytest.fixture
|
|
def project_scope(
|
|
user_root: Path,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> tuple[editor._EditorContext, Path]:
|
|
project_folder = tmp_path / "usr" / "projects" / "demo"
|
|
project_meta = project_folder / ".a0proj"
|
|
project_meta.mkdir(parents=True)
|
|
real_folder = editor.projects.get_project_folder
|
|
real_meta = editor.projects.get_project_meta
|
|
monkeypatch.setattr(
|
|
editor.projects,
|
|
"get_project_folder",
|
|
lambda name: str(project_folder) if name == "demo" else real_folder(name),
|
|
)
|
|
monkeypatch.setattr(
|
|
editor.projects,
|
|
"get_project_meta",
|
|
lambda name, *parts: (
|
|
str(project_meta.joinpath(*parts))
|
|
if name == "demo"
|
|
else real_meta(name, *parts)
|
|
),
|
|
)
|
|
return editor._EditorContext("demo"), project_meta / "agents"
|
|
|
|
|
|
def _write_manual_files(root: Path) -> dict[Path, bytes]:
|
|
manual_files = {
|
|
root / "prompts" / "manual.md": b"prompt",
|
|
root / "tools" / "manual.py": b"tool",
|
|
root / "extensions" / "manual.py": b"extension",
|
|
root / "skills" / "manual" / "SKILL.md": b"skill",
|
|
root / "assets" / "manual.bin": b"asset",
|
|
root / "plugins" / "manual" / "config.json": b"{}",
|
|
root / "unknown.bin": b"unknown",
|
|
}
|
|
for path, payload in manual_files.items():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(payload)
|
|
return manual_files
|
|
|
|
|
|
def test_new_easy_profile_writes_only_minimum_exact_files(user_root: Path) -> None:
|
|
instructions = "Keep this exact. \n\nNo rewrite."
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "legal-research",
|
|
"creating": True,
|
|
"editor_mode": "easy",
|
|
"metadata": {"set": {"title": "Legal Research"}, "reset": []},
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: instructions},
|
|
"reset": [],
|
|
},
|
|
"tool_policy": {"mode": "inherit"},
|
|
}
|
|
)
|
|
|
|
assert {path.relative_to(user_root).as_posix() for path in plan.changes} == {
|
|
"legal-research/agent.yaml",
|
|
f"legal-research/prompts/{editor.SPECIFICS_FILE}",
|
|
}
|
|
editor.apply_change_plan(plan)
|
|
|
|
profile = user_root / "legal-research"
|
|
assert yaml_helper.loads((profile / "agent.yaml").read_text()) == {
|
|
"title": "Legal Research"
|
|
}
|
|
assert (profile / "prompts" / editor.SPECIFICS_FILE).read_text() == instructions
|
|
assert not list(profile.rglob("*.json"))
|
|
assert stat.S_IMODE((profile / "agent.yaml").stat().st_mode) == 0o644
|
|
assert (
|
|
stat.S_IMODE((profile / "prompts" / editor.SPECIFICS_FILE).stat().st_mode)
|
|
== 0o644
|
|
)
|
|
|
|
|
|
def test_quick_create_uses_the_easy_sparse_save_path(user_root: Path) -> None:
|
|
profile_id, receipt = editor.save_easy_profile(
|
|
"Café Research",
|
|
"Verify sources and return concise citations.",
|
|
)
|
|
|
|
assert profile_id == "cafe-research"
|
|
assert len(receipt["written"]) == 2
|
|
assert yaml_helper.loads(
|
|
(user_root / profile_id / "agent.yaml").read_text(encoding="utf-8")
|
|
) == {"title": "Café Research"}
|
|
assert not list((user_root / profile_id).rglob("*.json"))
|
|
|
|
|
|
def test_editor_lifecycle_needs_no_model_or_utility_configuration(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import litellm
|
|
from plugins._model_config.helpers import model_config
|
|
|
|
def forbidden(*_args, **_kwargs):
|
|
raise AssertionError("the Agent Editor attempted a model request")
|
|
|
|
for name in ("completion", "acompletion", "embedding", "aembedding"):
|
|
monkeypatch.setattr(litellm, name, forbidden, raising=False)
|
|
monkeypatch.setattr(model_config, "get_presets", lambda: [{"name": "No utility"}])
|
|
monkeypatch.setattr(
|
|
model_config,
|
|
"resolve_config_settings",
|
|
lambda _settings: {
|
|
"chat_model": {"provider": "offline", "name": "main"},
|
|
"utility_model": {},
|
|
"embedding_model": {},
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
model_config,
|
|
"get_configured_preset_name",
|
|
lambda **_kwargs: "No utility",
|
|
)
|
|
monkeypatch.setattr(editor.tool_policy, "get_tool_catalog", lambda _agent: [])
|
|
monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
|
|
|
|
state = editor.build_editor_state("offline-editor")
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "offline-editor",
|
|
"creating": True,
|
|
"editor_mode": "easy",
|
|
"metadata": {"set": {"title": "Offline Editor"}, "reset": []},
|
|
"prompts": {"set": {editor.SPECIFICS_FILE: "Exact text."}, "reset": []},
|
|
}
|
|
)
|
|
receipt = plan.response()
|
|
|
|
assert state["model_presets"][0]["utility"] == {"provider": "", "name": ""}
|
|
assert editor.apply_change_plan(plan) == receipt
|
|
|
|
|
|
def test_state_catalog_is_complete_truthful_and_omits_internal_tools() -> None:
|
|
state = editor.build_editor_state("researcher")
|
|
|
|
assert all(
|
|
preset[slot]["provider"] and preset[slot]["name"]
|
|
for preset in state["model_presets"]
|
|
for slot in ("main", "utility", "embedding")
|
|
)
|
|
assert {prompt["group"] for prompt in state["prompts"]} == {
|
|
f"2.{index}" for index in range(1, 11)
|
|
}
|
|
assert all(
|
|
{
|
|
"effective",
|
|
"override",
|
|
"has_override",
|
|
"source",
|
|
"source_chain",
|
|
"state",
|
|
}.issubset(prompt)
|
|
for prompt in state["prompts"]
|
|
)
|
|
assert "AGENTS.md" not in {prompt["filename"] for prompt in state["prompts"]}
|
|
specifics = next(
|
|
prompt for prompt in state["prompts"]
|
|
if prompt["filename"] == editor.SPECIFICS_FILE
|
|
)
|
|
assert specifics["source_chain"] == ["Framework", "Researcher"]
|
|
assert any(
|
|
any(source.startswith("Plugin ·") for source in prompt["source_chain"])
|
|
for prompt in state["prompts"]
|
|
)
|
|
assert not {
|
|
item["name"] for item in state["tools"]["catalog"]
|
|
}.intersection({"response", "vision_load"})
|
|
|
|
|
|
def test_builtin_prompt_override_never_touches_bundled_profile(user_root: Path) -> None:
|
|
bundled = Path("agents/researcher")
|
|
before = {path: path.read_bytes() for path in bundled.rglob("*") if path.is_file()}
|
|
content = "Only the user-layer instructions change."
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: content},
|
|
"reset": [],
|
|
},
|
|
}
|
|
)
|
|
|
|
assert list(plan.changes) == [
|
|
user_root / "researcher" / "prompts" / editor.SPECIFICS_FILE
|
|
]
|
|
editor.apply_change_plan(plan)
|
|
assert all(path.read_bytes() == payload for path, payload in before.items())
|
|
|
|
reset = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"prompts": {"set": {}, "reset": [editor.SPECIFICS_FILE]},
|
|
}
|
|
)
|
|
editor.apply_change_plan(reset)
|
|
assert not (user_root / "researcher" / "prompts" / editor.SPECIFICS_FILE).exists()
|
|
|
|
|
|
def test_unrelated_empty_user_directory_survives_save(user_root: Path) -> None:
|
|
manual = user_root / "researcher" / "tools" / "reserved-for-manual-use"
|
|
manual.mkdir(parents=True)
|
|
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: "Sparse change only."},
|
|
"reset": [],
|
|
},
|
|
}
|
|
)
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert manual.is_dir()
|
|
|
|
|
|
def test_profile_collision_includes_disabled_plugin_profiles(
|
|
user_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
plugin_profile = tmp_path / "disabled-plugin" / "agents" / "reserved-agent"
|
|
plugin_profile.mkdir(parents=True)
|
|
monkeypatch.setattr(
|
|
editor.plugins,
|
|
"get_plugin_paths",
|
|
lambda *parts: [str(plugin_profile)] if parts == ("agents", "reserved-agent") else [],
|
|
)
|
|
|
|
assert editor.profile_exists("reserved-agent") is True
|
|
|
|
|
|
def test_metadata_empty_values_and_unknown_keys_survive(user_root: Path) -> None:
|
|
profile = user_root / "researcher"
|
|
profile.mkdir(parents=True)
|
|
metadata = profile / "agent.yaml"
|
|
metadata.write_text("custom_key: keep\ndescription: old\n", encoding="utf-8")
|
|
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"metadata": {
|
|
"set": {"description": "", "context": ""},
|
|
"reset": [],
|
|
},
|
|
}
|
|
)
|
|
editor.apply_change_plan(plan)
|
|
|
|
saved = yaml_helper.loads(metadata.read_text())
|
|
assert saved == {"custom_key": "keep", "description": "", "context": ""}
|
|
|
|
|
|
def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> None:
|
|
profile = user_root / "researcher" / "plugins"
|
|
model = profile / "_model_config" / "config.json"
|
|
tools = profile / "_tool_access" / "config.json"
|
|
skill = profile / "_skills" / "config.json"
|
|
for path, value in (
|
|
(model, {"manual": 1}),
|
|
(tools, {"manual": 2}),
|
|
(skill, {"active_skills": [{"name": "existing"}]}),
|
|
):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value), encoding="utf-8")
|
|
|
|
preset = editor.build_editor_state("researcher")["model_presets"][0]["name"]
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"model_preset": {"mode": "preset", "name": preset},
|
|
"tool_policy": {
|
|
"mode": "custom",
|
|
"default": "block",
|
|
"mcp_default": "allow",
|
|
"allowed": ["local:search_engine"],
|
|
"blocked": ["local:shell"],
|
|
},
|
|
"skill_policy": {
|
|
"mode": "custom",
|
|
"default": "block",
|
|
"allowed": ["a0-development"],
|
|
"blocked": [],
|
|
},
|
|
}
|
|
)
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert json.loads(model.read_text())["manual"] == 1
|
|
assert set(json.loads(tools.read_text())) >= {
|
|
"manual", "mode", "default", "mcp_default", "allowed", "blocked"
|
|
}
|
|
skill_data = json.loads(skill.read_text())
|
|
assert skill_data["active_skills"] == [{"name": "existing"}]
|
|
assert skill_data["visibility_policy"]["default"] == "block"
|
|
|
|
editor.apply_change_plan(editor.plan_remove_changes("researcher"))
|
|
|
|
assert json.loads(model.read_text()) == {"manual": 1}
|
|
assert json.loads(tools.read_text()) == {"manual": 2}
|
|
assert json.loads(skill.read_text()) == {
|
|
"active_skills": [{"name": "existing"}]
|
|
}
|
|
assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
|
|
|
|
|
|
def test_model_and_off_tool_choices_write_only_their_json_contracts(
|
|
user_root: Path,
|
|
) -> None:
|
|
preset = editor.build_editor_state("researcher")["model_presets"][1]["name"]
|
|
model_path = user_root / "researcher" / "plugins" / "_model_config" / "config.json"
|
|
tool_path = user_root / "researcher" / "plugins" / "_tool_access" / "config.json"
|
|
|
|
inherit = editor.build_change_plan(
|
|
{"profile_id": "researcher", "model_preset": {"mode": "inherit"}}
|
|
)
|
|
assert inherit.changes == {}
|
|
|
|
selected = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"model_preset": {"mode": "preset", "name": preset},
|
|
}
|
|
)
|
|
assert list(selected.changes) == [model_path]
|
|
assert json.loads(selected.changes[model_path].content) == {"model_preset": preset}
|
|
|
|
off = editor.build_change_plan(
|
|
{"profile_id": "researcher", "tool_policy": {"mode": "off"}}
|
|
)
|
|
assert list(off.changes) == [tool_path]
|
|
assert json.loads(off.changes[tool_path].content) == {
|
|
"mode": "custom",
|
|
"default": "block",
|
|
"mcp_default": "block",
|
|
"allowed": [],
|
|
"blocked": [],
|
|
}
|
|
|
|
|
|
def test_profile_summaries_do_not_build_removal_plans(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
profile = user_root / "researcher"
|
|
(profile / "plugins" / "manual").mkdir(parents=True)
|
|
(profile / "plugins" / "manual" / "config.json").write_text("{}")
|
|
monkeypatch.setattr(
|
|
editor,
|
|
"plan_remove_changes",
|
|
lambda *_args, **_kwargs: pytest.fail("profile summaries built a removal plan"),
|
|
)
|
|
|
|
assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
|
|
assert next(
|
|
item for item in editor.list_profiles() if item["id"] == "researcher"
|
|
)["scope_has_overrides"] is False
|
|
|
|
prompts = profile / "prompts"
|
|
prompts.mkdir()
|
|
(prompts / editor.SPECIFICS_FILE).write_text("Scoped instructions")
|
|
assert editor.build_profile_state("researcher")["scope_has_overrides"] is True
|
|
|
|
|
|
def test_project_tool_policy_reads_effective_access_and_writes_project_scope(
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(editor.tool_policy, "get_tool_catalog", lambda _agent: [])
|
|
effective_policy = {
|
|
"mode": "custom",
|
|
"default": "allow",
|
|
"allowed": [],
|
|
"blocked": ["local:shell"],
|
|
}
|
|
monkeypatch.setattr(editor.tool_policy, "get_policy", lambda _agent: effective_policy)
|
|
monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
|
|
context, project_agents = project_scope
|
|
|
|
state = editor.build_editor_state("researcher", context)
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"tool_policy": {"mode": "off"},
|
|
},
|
|
context,
|
|
)
|
|
|
|
assert state["tools"]["has_override"] is False
|
|
assert state["tools"]["effective_policy"] == effective_policy
|
|
assert list(plan.changes) == [
|
|
project_agents
|
|
/ "researcher"
|
|
/ "plugins"
|
|
/ "_tool_access"
|
|
/ "config.json"
|
|
]
|
|
|
|
|
|
def test_tri_state_tool_mcp_and_skill_policies_write_at_both_scopes(
|
|
user_root: Path,
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
) -> None:
|
|
tool_policy = {
|
|
"mode": "custom",
|
|
"default": "block",
|
|
"mcp_default": "allow",
|
|
"allowed": ["local:shell"],
|
|
"blocked": ["mcp:docs:write"],
|
|
}
|
|
skill_policy = {
|
|
"mode": "custom",
|
|
"default": "allow",
|
|
"allowed": ["Research"],
|
|
"blocked": ["Unsafe"],
|
|
}
|
|
patch = {
|
|
"profile_id": "researcher",
|
|
"tool_policy": tool_policy,
|
|
"skill_policy": skill_policy,
|
|
}
|
|
context, project_agents = project_scope
|
|
|
|
for plan, root in (
|
|
(editor.build_change_plan(patch), user_root),
|
|
(editor.build_change_plan(patch, context), project_agents),
|
|
):
|
|
editor.apply_change_plan(plan)
|
|
profile_root = root / "researcher" / "plugins"
|
|
assert json.loads(
|
|
(profile_root / "_tool_access" / "config.json").read_text()
|
|
) == tool_policy
|
|
assert json.loads(
|
|
(profile_root / "_skills" / "config.json").read_text()
|
|
)["visibility_policy"] == skill_policy
|
|
|
|
|
|
def test_project_agents_are_scope_owned_and_never_leak_global_writes(
|
|
user_root: Path,
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
) -> None:
|
|
context, project_agents = project_scope
|
|
profile_id = "project-helper"
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": profile_id,
|
|
"creating": True,
|
|
"editor_mode": "easy",
|
|
"metadata": {"set": {"title": "Project Helper"}, "reset": []},
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: "Help only this project."},
|
|
"reset": [],
|
|
},
|
|
},
|
|
context,
|
|
)
|
|
|
|
assert plan.project_name == "demo"
|
|
assert all(path.is_relative_to(project_agents / profile_id) for path in plan.changes)
|
|
assert not (user_root / profile_id).exists()
|
|
editor.apply_change_plan(plan)
|
|
|
|
state = editor.build_profile_state(profile_id, context)
|
|
assert state["scope_has_overrides"] is True
|
|
assert state["deletable"] is True
|
|
assert any(item["id"] == profile_id for item in editor.list_profiles(context))
|
|
assert all(item["id"] != profile_id for item in editor.list_profiles())
|
|
|
|
delete = editor.plan_delete_custom(profile_id, context)
|
|
assert delete.project_name == "demo"
|
|
editor.apply_change_plan(delete)
|
|
assert not (project_agents / profile_id).exists()
|
|
|
|
|
|
def test_project_customizations_inherit_global_agent_and_remove_only_project_files(
|
|
user_root: Path,
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
) -> None:
|
|
context, project_agents = project_scope
|
|
global_profile = user_root / "shared-helper"
|
|
global_profile.mkdir(parents=True)
|
|
(global_profile / "agent.yaml").write_text(
|
|
"title: Shared Helper\ndescription: Global description\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
inherited = editor.build_profile_state("shared-helper", context)
|
|
assert inherited["metadata"]["description"]["effective"] == "Global description"
|
|
assert inherited["scope_has_overrides"] is False
|
|
assert inherited["deletable"] is False
|
|
with pytest.raises(ValueError, match="created in this scope"):
|
|
editor.plan_delete_custom("shared-helper", context)
|
|
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "shared-helper",
|
|
"metadata": {"set": {"description": "Project description"}, "reset": []},
|
|
},
|
|
context,
|
|
)
|
|
project_yaml = project_agents / "shared-helper" / "agent.yaml"
|
|
assert list(plan.changes) == [project_yaml]
|
|
editor.apply_change_plan(plan)
|
|
assert editor.build_profile_state("shared-helper", context)["deletable"] is False
|
|
|
|
reset = editor.plan_remove_changes("shared-helper", context)
|
|
assert list(reset.changes) == [project_yaml]
|
|
editor.apply_change_plan(reset)
|
|
assert yaml_helper.loads((global_profile / "agent.yaml").read_text())["description"] == "Global description"
|
|
|
|
|
|
def test_project_scope_is_validated_at_api_and_apply_boundaries(
|
|
user_root: Path,
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context, _project_agents = project_scope
|
|
assert editor.projects.get_context_project_name(editor_context({"project_name": "demo"})) == "demo"
|
|
with pytest.raises(ValueError, match="Project not found"):
|
|
editor_context({"project_name": "missing-agent-editor-project"})
|
|
monkeypatch.setattr(
|
|
"plugins._agent_editor.api.agent_editor.AgentContext.get",
|
|
lambda _context_id: SimpleNamespace(
|
|
get_data=lambda _key, recursive=True: "../outside"
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="Invalid project name"):
|
|
editor_context({"context_id": "unsafe-project-context"})
|
|
|
|
forged = editor.ChangePlan(profile_id="researcher", project_name="demo")
|
|
forged.write(user_root / "researcher" / "agent.yaml", "title: Wrong scope\n")
|
|
with pytest.raises(ValueError, match="outside the selected profile directory"):
|
|
editor.apply_change_plan(forged)
|
|
|
|
|
|
def test_unavailable_skill_policy_ids_are_retained_in_editor_state(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config = user_root / "researcher" / "plugins" / "_skills" / "config.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"visibility_policy": {
|
|
"mode": "custom",
|
|
"default": "allow",
|
|
"allowed": [],
|
|
"blocked": ["removed-skill"],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
|
|
monkeypatch.setattr(
|
|
editor.skills,
|
|
"get_visibility_policy",
|
|
lambda _agent: {
|
|
"mode": "custom",
|
|
"default": "allow",
|
|
"allowed": [],
|
|
"blocked": ["removed-skill"],
|
|
},
|
|
)
|
|
|
|
state = editor.build_editor_state("researcher")
|
|
|
|
assert state["skills"]["policy"]["blocked"] == ["removed-skill"]
|
|
assert state["skills"]["catalog"] == [
|
|
{
|
|
"name": "removed-skill",
|
|
"description": "",
|
|
"path": "removed-skill",
|
|
"origin": "Unavailable",
|
|
"hidden": True,
|
|
"tags": [],
|
|
"allowed_tools": [],
|
|
"available": False,
|
|
}
|
|
]
|
|
|
|
|
|
def test_display_title_change_keeps_profile_id_and_builtin_delete_is_rejected(
|
|
user_root: Path,
|
|
) -> None:
|
|
with pytest.raises(ValueError, match="created in this scope"):
|
|
editor.plan_delete_custom("researcher")
|
|
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"metadata": {"set": {"title": "Renamed Display"}, "reset": []},
|
|
}
|
|
)
|
|
assert list(plan.changes) == [user_root / "researcher" / "agent.yaml"]
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert (user_root / "researcher" / "agent.yaml").is_file()
|
|
assert not (user_root / "renamed-display").exists()
|
|
|
|
|
|
def test_profile_availability_is_a_sparse_global_override(user_root: Path) -> None:
|
|
disabled = editor.plan_profile_enabled("researcher", False)
|
|
|
|
assert list(disabled.changes) == [user_root / "researcher" / "agent.yaml"]
|
|
editor.apply_change_plan(disabled)
|
|
assert yaml_helper.loads(
|
|
(user_root / "researcher" / "agent.yaml").read_text(encoding="utf-8")
|
|
) == {"enabled": False}
|
|
|
|
restored = editor.plan_profile_enabled("researcher", True)
|
|
editor.apply_change_plan(restored)
|
|
assert not (user_root / "researcher" / "agent.yaml").exists()
|
|
|
|
|
|
def test_default_profile_can_be_disabled_when_another_profile_is_available(
|
|
user_root: Path,
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
) -> None:
|
|
context, _ = project_scope
|
|
|
|
editor.set_profile_enabled("default", False)
|
|
assert yaml_helper.loads(
|
|
(user_root / "default" / "agent.yaml").read_text(encoding="utf-8")
|
|
) == {"enabled": False}
|
|
editor.set_profile_enabled("default", True)
|
|
assert not (user_root / "default" / "agent.yaml").exists()
|
|
|
|
editor.set_profile_enabled("default", False, context)
|
|
assert editor.projects.load_project_subagents("demo") == {
|
|
"default": {"enabled": False}
|
|
}
|
|
|
|
|
|
def test_last_available_profile_cannot_be_disabled(
|
|
user_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
editor.subagents,
|
|
"get_available_agents_dict",
|
|
lambda _project=None: {
|
|
"default": editor.subagents.SubAgentListItem(name="default")
|
|
},
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="At least one agent profile"):
|
|
editor.set_profile_enabled("default", False)
|
|
|
|
assert not (user_root / "default").exists()
|
|
|
|
|
|
def test_duplicate_profile_materializes_the_effective_profile(
|
|
user_root: Path,
|
|
) -> None:
|
|
plan, title = editor.plan_duplicate_profile("developer")
|
|
|
|
assert plan.profile_id == "developer-1"
|
|
assert title == "Developer 1"
|
|
assert all(path.is_relative_to(user_root / "developer-1") for path in plan.changes)
|
|
editor.apply_change_plan(plan)
|
|
|
|
duplicate = user_root / "developer-1"
|
|
metadata = yaml_helper.loads(
|
|
(duplicate / "agent.yaml").read_text(encoding="utf-8")
|
|
)
|
|
assert metadata["title"] == "Developer 1"
|
|
assert metadata["description"] == "Agent specialized in complex software development."
|
|
assert "enabled" not in metadata
|
|
assert (duplicate / "prompts" / editor.SPECIFICS_FILE).read_bytes() == (
|
|
Path("agents/developer/prompts") / editor.SPECIFICS_FILE
|
|
).read_bytes()
|
|
assert not (duplicate / "AGENTS.md").exists()
|
|
|
|
next_plan, next_title = editor.plan_duplicate_profile("developer")
|
|
assert next_plan.profile_id == "developer-2"
|
|
assert next_title == "Developer 2"
|
|
|
|
|
|
def test_duplicate_profile_targets_the_selected_project(
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
) -> None:
|
|
context, project_agents = project_scope
|
|
|
|
plan, title = editor.plan_duplicate_profile("developer", context)
|
|
|
|
assert plan.project_name == "demo"
|
|
assert plan.profile_id == "developer-1"
|
|
assert title == "Developer 1"
|
|
assert all(
|
|
path.is_relative_to(project_agents / "developer-1")
|
|
for path in plan.changes
|
|
)
|
|
|
|
|
|
def test_project_profile_availability_uses_project_settings(
|
|
project_scope: tuple[editor._EditorContext, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context, _ = project_scope
|
|
reconciled: list[tuple[tuple, dict]] = []
|
|
monkeypatch.setattr(
|
|
editor.projects,
|
|
"reconcile_agent_profiles",
|
|
lambda *args, **kwargs: reconciled.append((args, kwargs)),
|
|
)
|
|
monkeypatch.setattr(
|
|
editor.subagents,
|
|
"get_agents_dict",
|
|
lambda _project=None: {
|
|
"default": editor.subagents.SubAgentListItem(
|
|
name="default", enabled=True
|
|
),
|
|
"researcher": editor.subagents.SubAgentListItem(
|
|
name="researcher", enabled=True
|
|
)
|
|
},
|
|
)
|
|
|
|
editor.set_profile_enabled("researcher", False, context)
|
|
|
|
assert editor.projects.load_project_subagents("demo") == {
|
|
"researcher": {"enabled": False}
|
|
}
|
|
|
|
editor.set_profile_enabled("researcher", True, context)
|
|
|
|
assert editor.projects.load_project_subagents("demo") == {}
|
|
assert reconciled == [(("demo",), {"all_scopes": False})]
|
|
|
|
|
|
def test_save_rolls_back_every_file_after_commit_failure(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
root = user_root / "rollback-agent"
|
|
first = root / "agent.yaml"
|
|
second = root / "prompts" / editor.SPECIFICS_FILE
|
|
second.parent.mkdir(parents=True)
|
|
first.write_bytes(b"title: Before\n")
|
|
second.write_bytes(b"before")
|
|
plan = editor.ChangePlan(profile_id="rollback-agent")
|
|
plan.write(first, b"title: After\n")
|
|
plan.write(second, b"after")
|
|
|
|
original_replace = editor.os.replace
|
|
calls = 0
|
|
invalidations: list[bool] = []
|
|
monkeypatch.setattr(
|
|
editor,
|
|
"_invalidate_profile_caches",
|
|
lambda: invalidations.append(True),
|
|
)
|
|
|
|
def fail_second(source, destination):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 2:
|
|
raise OSError("simulated commit failure")
|
|
return original_replace(source, destination)
|
|
|
|
monkeypatch.setattr(editor.os, "replace", fail_second)
|
|
with pytest.raises(OSError, match="simulated"):
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert first.read_bytes() == b"title: Before\n"
|
|
assert second.read_bytes() == b"before"
|
|
assert invalidations == []
|
|
|
|
|
|
def test_remove_my_changes_preserves_manual_and_unknown_files(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
root = user_root / "researcher"
|
|
prompt = root / "prompts" / editor.SPECIFICS_FILE
|
|
manual_files = _write_manual_files(root)
|
|
prompt.parent.mkdir(parents=True, exist_ok=True)
|
|
prompt.write_text("override", encoding="utf-8")
|
|
(root / "agent.yaml").write_text(
|
|
"title: Mine\nunknown_key: keep\n", encoding="utf-8"
|
|
)
|
|
tool_config = root / "plugins" / "_tool_access" / "config.json"
|
|
tool_config.parent.mkdir(parents=True)
|
|
tool_config.write_text(
|
|
json.dumps({"mode": "custom", "default": "block", "manual": True}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
real_catalog = editor.prompt_catalog
|
|
|
|
def catalog(agent):
|
|
items = real_catalog(agent)
|
|
for item in items:
|
|
if item["filename"] == editor.SPECIFICS_FILE:
|
|
item.update({"has_override": True, "inherited_source": "agents/researcher"})
|
|
return items
|
|
|
|
monkeypatch.setattr(editor, "prompt_catalog", catalog)
|
|
plan = editor.plan_remove_changes("researcher")
|
|
assert set(manual_files).isdisjoint(plan.changes)
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert all(path.read_bytes() == payload for path, payload in manual_files.items())
|
|
assert yaml_helper.loads((root / "agent.yaml").read_text()) == {
|
|
"unknown_key": "keep"
|
|
}
|
|
assert json.loads(tool_config.read_text()) == {"manual": True}
|
|
|
|
|
|
def test_destructive_cleanup_deletes_only_its_enumerated_plan(
|
|
user_root: Path,
|
|
) -> None:
|
|
root = user_root / "researcher"
|
|
planned_files = _write_manual_files(root)
|
|
agent_yaml = root / "agent.yaml"
|
|
agent_yaml.write_text("title: Mine\n", encoding="utf-8")
|
|
planned_files[agent_yaml] = agent_yaml.read_bytes()
|
|
|
|
plan = editor.plan_remove_changes("researcher", destructive=True)
|
|
assert set(plan.changes) == set(planned_files)
|
|
|
|
unplanned = root / "created-after-plan.txt"
|
|
unplanned.write_text("keep", encoding="utf-8")
|
|
editor.apply_change_plan(plan)
|
|
|
|
assert all(not path.exists() for path in planned_files)
|
|
assert unplanned.read_text(encoding="utf-8") == "keep"
|
|
|
|
|
|
def test_mixed_save_matches_plan_preserves_every_unrelated_family_and_refreshes_cache(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
root = user_root / "researcher"
|
|
manual_files = _write_manual_files(root)
|
|
preset = editor.build_editor_state("researcher")["model_presets"][1]["name"]
|
|
cleared: list[object] = []
|
|
monkeypatch.setattr(editor.cache, "clear", lambda area: cleared.append(area))
|
|
monkeypatch.setattr(
|
|
editor.plugins,
|
|
"clear_plugin_cache",
|
|
lambda names: cleared.append(tuple(names)),
|
|
)
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"metadata": {"set": {"description": "Scoped"}, "reset": []},
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: "Only this prompt."},
|
|
"reset": [],
|
|
},
|
|
"model_preset": {"mode": "preset", "name": preset},
|
|
}
|
|
)
|
|
expected = plan.response()
|
|
|
|
assert {
|
|
path.relative_to(user_root).as_posix()
|
|
for path, change in plan.changes.items()
|
|
if change.action == "write"
|
|
} == {
|
|
"researcher/agent.yaml",
|
|
f"researcher/prompts/{editor.SPECIFICS_FILE}",
|
|
"researcher/plugins/_model_config/config.json",
|
|
}
|
|
assert editor.apply_change_plan(plan) == expected
|
|
assert cleared == [
|
|
editor.subagents.PATHS_CACHE_AREA,
|
|
("_agent_editor", "_model_config", "_tool_access", "_skills"),
|
|
]
|
|
assert all(path.read_bytes() == payload for path, payload in manual_files.items())
|
|
|
|
|
|
def test_empty_prompt_override_and_selected_project_scope_are_distinct(
|
|
user_root: Path,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
framework = tmp_path / "framework"
|
|
user_prompts = user_root / "researcher" / "prompts"
|
|
project_meta = tmp_path / "project" / ".a0proj"
|
|
project_prompts = project_meta / "agents" / "researcher" / "prompts"
|
|
for path, text in (
|
|
(framework / editor.SPECIFICS_FILE, "framework"),
|
|
(user_prompts / editor.SPECIFICS_FILE, ""),
|
|
(project_prompts / editor.SPECIFICS_FILE, "project"),
|
|
):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
editor.subagents,
|
|
"get_paths",
|
|
lambda _agent, *parts: (
|
|
[str(user_prompts), str(framework)]
|
|
if not editor.projects.get_context_project_name(_agent.context)
|
|
else [str(project_prompts), str(user_prompts), str(framework)]
|
|
),
|
|
)
|
|
original_meta = editor.projects.get_project_meta
|
|
monkeypatch.setattr(
|
|
editor.projects,
|
|
"get_project_meta",
|
|
lambda name, *parts: str(project_meta.joinpath(*parts))
|
|
if name == "acceptance-project"
|
|
else original_meta(name, *parts),
|
|
)
|
|
|
|
empty = next(
|
|
item
|
|
for item in editor.prompt_catalog(editor.EditorAgent("researcher"))
|
|
if item["filename"] == editor.SPECIFICS_FILE
|
|
)
|
|
project = next(
|
|
item
|
|
for item in editor.prompt_catalog(
|
|
editor.EditorAgent(
|
|
"researcher",
|
|
editor._EditorContext("acceptance-project"),
|
|
)
|
|
)
|
|
if item["filename"] == editor.SPECIFICS_FILE
|
|
)
|
|
|
|
assert empty["state"] == "Overridden here (empty)"
|
|
assert empty["has_override"] is True
|
|
assert empty["effective"] == ""
|
|
assert project["state"] == "Overridden here"
|
|
assert project["effective"] == "project"
|
|
assert project["source_chain"][-2:] == ["Global", "Your override"]
|
|
|
|
reset = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"prompts": {"set": {}, "reset": [editor.SPECIFICS_FILE]},
|
|
}
|
|
)
|
|
assert list(reset.changes) == [user_prompts / editor.SPECIFICS_FILE]
|
|
assert next(iter(reset.changes.values())).action == "delete"
|
|
|
|
|
|
def test_avatar_is_normalized_and_avatar_only_edit_is_sparse(user_root: Path) -> None:
|
|
from PIL import Image
|
|
|
|
source = BytesIO()
|
|
Image.new("RGB", (800, 400), "red").save(source, format="PNG")
|
|
upload = FileStorage(stream=BytesIO(source.getvalue()), filename="avatar.png")
|
|
staged = editor.stage_avatar(upload)
|
|
plan = editor.build_change_plan(
|
|
{
|
|
"profile_id": "researcher",
|
|
"metadata": {
|
|
"set": {"avatar": {"kind": "image", "token": staged["token"]}},
|
|
"reset": [],
|
|
},
|
|
}
|
|
)
|
|
|
|
assert {path.relative_to(user_root).as_posix() for path in plan.changes} == {
|
|
"researcher/agent.yaml",
|
|
"researcher/assets/avatar.webp",
|
|
}
|
|
editor.apply_change_plan(plan)
|
|
avatar = user_root / "researcher" / "assets" / "avatar.webp"
|
|
with Image.open(avatar) as normalized:
|
|
assert normalized.format == "WEBP"
|
|
assert normalized.size == (editor.AVATAR_SIZE, editor.AVATAR_SIZE)
|
|
assert not normalized.getexif()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncated_avatar_is_a_validation_error(user_root: Path) -> None:
|
|
from PIL import Image
|
|
|
|
source = BytesIO()
|
|
Image.new("RGB", (32, 32), "red").save(source, format="PNG")
|
|
upload = FileStorage(
|
|
stream=BytesIO(source.getvalue()[:-24]),
|
|
filename="truncated.png",
|
|
)
|
|
response = await AgentEditorAvatar(None, None).process( # type: ignore[arg-type]
|
|
{},
|
|
SimpleNamespace(method="POST", files={"avatar": upload}),
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "valid PNG, JPEG, or WebP" in response.get_data(as_text=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_destructive_removal_requires_a_boolean_and_confirmation(
|
|
user_root: Path,
|
|
) -> None:
|
|
profile_root = user_root / "researcher"
|
|
manual = profile_root / "manual.txt"
|
|
manual.parent.mkdir(parents=True)
|
|
manual.write_text("keep until confirmed", encoding="utf-8")
|
|
handler = AgentEditor(None, None) # type: ignore[arg-type]
|
|
|
|
malformed = await handler.process(
|
|
{
|
|
"action": "remove_changes",
|
|
"profile_id": "researcher",
|
|
"destructive": "false",
|
|
},
|
|
None, # type: ignore[arg-type]
|
|
)
|
|
unconfirmed = await handler.process(
|
|
{
|
|
"action": "remove_changes",
|
|
"profile_id": "researcher",
|
|
"destructive": True,
|
|
},
|
|
None, # type: ignore[arg-type]
|
|
)
|
|
|
|
assert malformed.status_code == 400
|
|
assert unconfirmed.status_code == 400
|
|
assert manual.read_text(encoding="utf-8") == "keep until confirmed"
|
|
|
|
applied = await handler.process(
|
|
{
|
|
"action": "remove_changes",
|
|
"profile_id": "researcher",
|
|
"destructive": True,
|
|
"confirm": True,
|
|
},
|
|
None, # type: ignore[arg-type]
|
|
)
|
|
assert applied["ok"] is True
|
|
assert not manual.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_running_custom_profile_cannot_be_deleted(
|
|
user_root: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
profile_root = user_root / "running-custom"
|
|
profile_root.mkdir(parents=True)
|
|
(profile_root / "agent.yaml").write_text(
|
|
"title: Running custom\n",
|
|
encoding="utf-8",
|
|
)
|
|
running = SimpleNamespace(
|
|
config=SimpleNamespace(profile="running-custom"),
|
|
is_running=lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"plugins._agent_editor.api.agent_editor.AgentContext.all",
|
|
lambda: [running],
|
|
)
|
|
|
|
response = await AgentEditor(None, None).process( # type: ignore[arg-type]
|
|
{
|
|
"action": "delete",
|
|
"profile_id": "running-custom",
|
|
"confirm": True,
|
|
},
|
|
None, # type: ignore[arg-type]
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "running" in response.get_data(as_text=True)
|
|
assert profile_root.is_dir()
|
|
|
|
|
|
def test_stale_create_plan_cannot_overwrite_a_new_profile(user_root: Path) -> None:
|
|
patch = {
|
|
"profile_id": "create-race",
|
|
"creating": True,
|
|
"editor_mode": "easy",
|
|
"metadata": {"set": {"title": "Create race"}, "reset": []},
|
|
"prompts": {
|
|
"set": {editor.SPECIFICS_FILE: "First writer wins."},
|
|
"reset": [],
|
|
},
|
|
}
|
|
first = editor.build_change_plan(patch)
|
|
stale = editor.build_change_plan(patch)
|
|
|
|
editor.apply_change_plan(first)
|
|
with pytest.raises(ValueError, match="created before this save completed"):
|
|
editor.apply_change_plan(stale)
|
|
|
|
assert yaml_helper.loads(
|
|
(user_root / "create-race" / "agent.yaml").read_text(encoding="utf-8")
|
|
) == {"title": "Create race"}
|
|
|
|
|
|
def test_settings_default_profile_catalog_uses_global_availability(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from helpers import settings
|
|
|
|
monkeypatch.setattr(
|
|
settings.subagents,
|
|
"get_available_agents_dict",
|
|
lambda _project: {
|
|
"default": settings.subagents.SubAgentListItem(
|
|
name="default", title="Default"
|
|
),
|
|
"agent0": settings.subagents.SubAgentListItem(
|
|
name="agent0", title="Agent 0"
|
|
),
|
|
},
|
|
)
|
|
configured = settings.get_default_settings().copy()
|
|
configured["agent_profile"] = "disabled-profile"
|
|
|
|
options = settings.convert_out(configured)["additional"]["agent_subdirs"]
|
|
|
|
assert options == [
|
|
{"value": "agent0", "label": "Agent 0"},
|
|
{
|
|
"value": "disabled-profile",
|
|
"label": "disabled-profile (unavailable)",
|
|
},
|
|
]
|
|
|
|
|
|
def test_shared_profile_catalog_drives_generic_and_web_selectors(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from api import agents
|
|
from helpers import integration_commands, subagents
|
|
|
|
profiles = [{"key": "agent0", "label": "Agent 0"}]
|
|
monkeypatch.setattr(subagents, "get_all_agents_list", lambda: profiles)
|
|
|
|
handler = agents.Agents.__new__(agents.Agents)
|
|
response = asyncio.run(handler.process({"action": "list"}, None))
|
|
assert [item["key"] for item in response["data"]] == ["agent0"]
|
|
|
|
context = SimpleNamespace(
|
|
agent0=SimpleNamespace(config=SimpleNamespace(profile="default")),
|
|
is_running=lambda: False,
|
|
)
|
|
status = integration_commands._handle_agent(context, "")
|
|
assert "Current agent: default" in status
|
|
assert "Agent 0 (agent0)" in status
|
|
assert "Default (default)" not in status
|
|
assert "was not found" in integration_commands._handle_agent(context, "default")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("relative_path", "patch", "label"),
|
|
(
|
|
(
|
|
"agent.yaml",
|
|
{"metadata": {"set": {"description": "new"}, "reset": []}},
|
|
"profile metadata",
|
|
),
|
|
(
|
|
"plugins/_tool_access/config.json",
|
|
{"tool_policy": {"mode": "off"}},
|
|
"tool policy configuration",
|
|
),
|
|
),
|
|
)
|
|
def test_invalid_existing_authored_files_are_never_overwritten(
|
|
user_root: Path,
|
|
relative_path: str,
|
|
patch: dict,
|
|
label: str,
|
|
) -> None:
|
|
path = user_root / "researcher" / relative_path
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
original = b"{ definitely not valid\n"
|
|
path.write_bytes(original)
|
|
|
|
with pytest.raises(ValueError, match=label):
|
|
editor.build_change_plan({"profile_id": "researcher", **patch})
|
|
|
|
assert path.read_bytes() == original
|
|
|
|
|
|
def test_editor_preview_raw_reads_markdown_without_running_dynamic_processor(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
prompt_root = tmp_path / "prompts"
|
|
prompt_root.mkdir()
|
|
(prompt_root / editor.SPECIFICS_FILE).write_text(
|
|
"Raw {{value}}", encoding="utf-8"
|
|
)
|
|
(prompt_root / "agent.system.main.specifics.py").write_text(
|
|
"raise RuntimeError('must not run')\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
editor.subagents,
|
|
"get_paths",
|
|
lambda *_args, **_kwargs: [str(prompt_root)],
|
|
)
|
|
monkeypatch.setattr(
|
|
editor.files,
|
|
"read_prompt_file",
|
|
lambda *_args, **_kwargs: pytest.fail("dynamic prompt loader ran"),
|
|
)
|
|
|
|
item = next(
|
|
item
|
|
for item in editor.prompt_catalog(editor.EditorAgent("researcher"))
|
|
if item["filename"] == editor.SPECIFICS_FILE
|
|
)
|
|
|
|
assert item["effective"] == "Raw {{value}}"
|
|
assert item["preview"] == "Raw {{value}}"
|
|
assert item["dynamic_processor"] is True
|
|
|
|
|
|
def test_editor_api_keeps_default_auth_and_csrf_protection() -> None:
|
|
for handler in (AgentEditor, AgentEditorAvatar):
|
|
assert handler.requires_auth() is True
|
|
assert handler.requires_csrf() is True
|
|
|
|
|
|
def test_backend_has_no_legacy_save_or_model_request_path() -> None:
|
|
sources = "\n".join(
|
|
path.read_text(encoding="utf-8")
|
|
for path in (
|
|
Path(editor.__file__),
|
|
Path("plugins/_agent_editor/api/agent_editor.py"),
|
|
Path("plugins/_agent_editor/api/agent_editor_avatar.py"),
|
|
)
|
|
)
|
|
|
|
assert "save_agent_data" not in sources
|
|
assert "call_llm" not in sources
|
|
assert "call_utility_model" not in sources
|
|
assert "litellm" not in sources
|