agent-zero/tests/test_agent_editor.py
Alessandro 282f13982b fix agent profile switching logic
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.
2026-08-12 14:34:15 +02:00

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