agent-zero/tests/test_projects.py
Alessandro db4913ff76 Avoid rewriting unrelated chats on project changes
Project updates and deletion previously persisted every loaded chat, while matching chats were written twice through the existing activation helpers.

Keep the established project lifecycle flow and remove the unconditional writes so only affected chats are saved once. Add focused regression coverage and document the persistence contract.
2026-08-12 14:34:15 +02:00

510 lines
17 KiB
Python

import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
import initialize
from agent import AgentConfig, AgentContext
from helpers import dirty_json, files, persist_chat, projects, subagents
from helpers import state_monitor_integration
def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
(tmp_path / "usr" / "projects").mkdir(parents=True, exist_ok=True)
(tmp_path / "usr" / "plugins").mkdir(parents=True, exist_ok=True)
(tmp_path / "plugins").mkdir(parents=True, exist_ok=True)
@pytest.mark.parametrize(
"destination_project", ["project-y", None], ids=["project", "global"]
)
def test_project_switch_resets_only_profiles_missing_from_the_new_scope(
monkeypatch, destination_project
):
context_id = "ctx-project-profile-switch"
AgentContext.remove(context_id)
context = AgentContext(
config=AgentConfig(mcp_servers="", profile="project-only"),
id=context_id,
set_current=False,
)
monkeypatch.setattr(
projects,
"load_edit_project_data",
lambda name: {"title": name.title(), "color": ""},
)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
monkeypatch.setattr(
subagents,
"get_agents_dict",
lambda project_name=None: {
"agent0": subagents.SubAgentListItem(name="agent0"),
**(
{
"project-only": subagents.SubAgentListItem(
name="project-only"
)
}
if project_name == "project-x"
else {}
),
},
)
monkeypatch.setattr(
initialize,
"initialize_agent",
lambda override_settings=None: AgentConfig(
mcp_servers="",
profile=(override_settings or {}).get("agent_profile", "agent0"),
),
)
try:
projects.activate_project(context_id, "project-x", mark_dirty=False)
assert context.config.profile == "project-only"
if destination_project:
projects.activate_project(
context_id, destination_project, mark_dirty=False
)
else:
projects.deactivate_project(context_id, mark_dirty=False)
assert context.config.profile == "agent0"
assert context.agent0.config.profile == "agent0"
finally:
AgentContext.remove(context_id)
def test_project_agent_availability_retains_project_only_profiles(
monkeypatch,
) -> None:
monkeypatch.setattr(
subagents,
"get_agents_dict",
lambda project_name=None: {
"global": subagents.SubAgentListItem(name="global", enabled=True),
**(
{
"project-only": subagents.SubAgentListItem(
name="project-only", enabled=True
)
}
if project_name == "demo"
else {}
),
},
)
assert projects._normalize_subagents(
{
"global": {"enabled": True},
"project-only": {"enabled": False},
"missing": {"enabled": False},
},
"demo",
) == {"project-only": {"enabled": False}}
def test_project_profile_toggle_preserves_other_entries_and_refuses_bad_json(
monkeypatch,
tmp_path: Path,
) -> None:
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
availability = meta / "agents.json"
monkeypatch.setattr(
subagents,
"get_agents_dict",
lambda _project=None: {
"default": subagents.SubAgentListItem(name="default", enabled=True),
"researcher": subagents.SubAgentListItem(
name="researcher", enabled=True
),
},
)
availability.write_text(
'{"default":{"enabled":false}}',
encoding="utf-8",
)
projects.set_project_subagent_enabled("demo", "researcher", False)
assert dirty_json.parse(availability.read_text(encoding="utf-8")) == {
"default": {"enabled": False},
"researcher": {"enabled": False},
}
broken = b'{"default":'
availability.write_bytes(broken)
with pytest.raises(ValueError, match="Project agent availability"):
projects.set_project_subagent_enabled("demo", "researcher", True)
assert availability.read_bytes() == broken
def test_project_edit_ignores_stale_agent_availability(
monkeypatch,
tmp_path: Path,
) -> None:
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
(meta / "project.json").write_text('{"title":"Demo"}', encoding="utf-8")
availability = meta / "agents.json"
original = b'{"default":{"enabled":false}}'
availability.write_bytes(original)
monkeypatch.setattr("helpers.git.get_repo_status", lambda _path: {})
monkeypatch.setattr(projects, "reactivate_project_in_chats", lambda _name: None)
extended: list[dict] = []
monkeypatch.setattr(
projects,
"save_project_extended_data",
lambda _name, data: extended.append(data),
)
loaded = projects.load_edit_project_data("demo")
projects.update_project(
"demo",
{
**loaded,
"title": "Renamed",
"subagents": {"default": {"enabled": True}},
},
)
assert "subagents" not in loaded
assert availability.read_bytes() == original
assert extended and all("subagents" not in data for data in extended)
def test_profile_reconciliation_uses_an_available_fallback(monkeypatch) -> None:
context_id = "ctx-profile-availability-fallback"
AgentContext.remove(context_id)
context = AgentContext(
config=AgentConfig(mcp_servers="", profile="disabled"),
id=context_id,
set_current=False,
)
monkeypatch.setattr(
subagents,
"get_available_agents_dict",
lambda _project_name: {
"researcher": subagents.SubAgentListItem(name="researcher")
},
)
monkeypatch.setattr(
initialize,
"initialize_agent",
lambda override_settings=None: AgentConfig(
mcp_servers="",
profile=(override_settings or {}).get("agent_profile", "default"),
),
)
try:
assert projects.reconcile_agent_profile(context, None) is True
assert context.config.profile == "researcher"
assert context.agent0.config.profile == "researcher"
finally:
AgentContext.remove(context_id)
def test_context_lookup_reconciles_only_new_contexts(monkeypatch) -> None:
from helpers.context_utils import use_context
existing_id = "ctx-existing-profile"
created_id = "ctx-new-profile"
AgentContext.remove(existing_id)
AgentContext.remove(created_id)
existing = AgentContext(
config=AgentConfig(mcp_servers="", profile="default"),
id=existing_id,
set_current=False,
)
reconciled: list[str] = []
monkeypatch.setattr(
initialize,
"initialize_agent",
lambda: AgentConfig(mcp_servers="", profile="default"),
)
monkeypatch.setattr(
projects,
"reconcile_agent_profile",
lambda context, _project: reconciled.append(context.id),
)
try:
assert use_context(threading.RLock(), existing_id) is existing
assert reconciled == []
assert use_context(threading.RLock(), created_id).id == created_id
assert reconciled == [created_id]
finally:
AgentContext.remove(existing_id)
AgentContext.remove(created_id)
@pytest.mark.parametrize(
("all_scopes", "expected"),
[
(False, ["global-changed"]),
(True, ["global-changed", "project-changed"]),
],
)
def test_bulk_profile_reconciliation_persists_only_changed_chats(
monkeypatch, all_scopes: bool, expected: list[str]
) -> None:
unchanged = SimpleNamespace(id="global-unchanged", project=None)
global_changed = SimpleNamespace(id="global-changed", project=None)
project_changed = SimpleNamespace(id="project-changed", project="demo")
saved: list[str] = []
dirty: list[str] = []
catalog_lookups: list[str | None] = []
monkeypatch.setattr(
AgentContext,
"all",
classmethod(
lambda _cls: [unchanged, global_changed, project_changed]
),
)
monkeypatch.setattr(
projects, "get_context_project_name", lambda context: context.project
)
monkeypatch.setattr(
projects,
"reconcile_agent_profile",
lambda context, _project, _available: context is not unchanged,
)
monkeypatch.setattr(
subagents,
"get_available_agents_dict",
lambda project: catalog_lookups.append(project) or {},
)
monkeypatch.setattr(
persist_chat, "save_tmp_chat", lambda context: saved.append(context.id)
)
monkeypatch.setattr(
state_monitor_integration,
"mark_dirty_for_context",
lambda context_id, **_kwargs: dirty.append(context_id),
)
projects.reconcile_agent_profiles(None, all_scopes=all_scopes)
assert saved == expected
assert dirty == expected
assert catalog_lookups == ([None, "demo"] if all_scopes else [None])
def test_project_refresh_touches_only_matching_chats(monkeypatch) -> None:
contexts = [
SimpleNamespace(id="matching", get_data=lambda _key: "demo"),
SimpleNamespace(id="unrelated", get_data=lambda _key: "other"),
]
calls: list[tuple] = []
monkeypatch.setattr(
AgentContext, "all", staticmethod(lambda: contexts)
)
monkeypatch.setattr(
projects,
"activate_project",
lambda context_id, name, *, mark_dirty: calls.append(
("activate", context_id, name, mark_dirty)
),
)
monkeypatch.setattr(
projects,
"deactivate_project",
lambda context_id, *, mark_dirty: calls.append(
("deactivate", context_id, mark_dirty)
),
)
monkeypatch.setattr(state_monitor_integration, "mark_dirty_all", lambda **_kwargs: None)
projects.reactivate_project_in_chats("demo")
projects.deactivate_project_in_chats("demo")
assert calls == [
("activate", "matching", "demo", False),
("deactivate", "matching", False),
]
def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
(meta / "project.json").write_text('{"title": "Demo"}', encoding="utf-8")
data = projects.load_basic_project_data("demo")
assert data["include_agents_md"] is True
projects.save_project_header("demo", data)
saved = dirty_json.parse((meta / "project.json").read_text(encoding="utf-8"))
assert saved["include_agents_md"] is True
def test_project_mcp_servers_persist_in_project_meta(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
config = '{"mcpServers":{"demo":{"url":"https://example.com/mcp"}}}'
projects.create_project(
"demo",
{
"title": "Demo",
"mcp_servers": config,
},
)
assert projects.load_project_mcp_servers("demo") == config
assert projects.load_edit_project_data("demo")["mcp_servers"] == config
updated = '{"mcpServers":{"other":{"command":"uvx","args":["pkg"]}}}'
projects.save_project_mcp_servers("demo", updated)
assert projects.load_project_mcp_servers("demo") == updated
def test_project_mcp_servers_reject_path_names(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
for name in ("../escape", "nested/project", ".", "..", ""):
try:
projects.save_project_mcp_servers(name, '{"mcpServers":{}}')
except ValueError:
pass
else:
raise AssertionError(f"Expected invalid project name: {name!r}")
def test_project_creation_creates_skills_folder(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
projects.create_project("demo", {"title": "Demo"})
assert (tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "skills").is_dir()
def test_project_load_repairs_missing_skills_folder(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
meta.mkdir(parents=True)
(meta / "project.json").write_text('{"title": "Demo"}', encoding="utf-8")
assert not (meta / "skills").exists()
projects.load_edit_project_data("demo")
assert (meta / "skills").is_dir()
def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
projects.create_project(
"demo",
{
"title": "Demo",
"instructions": "Main project rule.",
},
)
project_root = tmp_path / "usr" / "projects" / "demo"
(project_root / "AGENTS.md").write_text("Root AGENTS rule.", encoding="utf-8")
(
project_root / ".a0proj" / "instructions" / "extra.md"
).write_text("Folder instruction rule.", encoding="utf-8")
prompt_vars = projects.build_system_prompt_vars("demo")
instructions = prompt_vars["project_instructions"]
assert "Main project rule." in instructions
assert instructions.count("## project instruction files") == 1
assert "## project instruction file\n" not in instructions
assert "### path: /a0/usr/projects/demo/AGENTS.md" in instructions
assert "Root AGENTS rule." in instructions
assert "### path: /a0/usr/projects/demo/.a0proj/instructions/extra.md" in instructions
assert "Folder instruction rule." in instructions
def test_project_system_prompt_prefers_agents_override_md(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
projects.create_project("demo", {"title": "Demo"})
project_root = tmp_path / "usr" / "projects" / "demo"
(project_root / "AGENTS.md").write_text("Standard rule.", encoding="utf-8")
(project_root / "AGENTS.override.md").write_text("Override rule.", encoding="utf-8")
instructions = projects.build_system_prompt_vars("demo")["project_instructions"]
assert "### path: /a0/usr/projects/demo/AGENTS.override.md" in instructions
assert "Override rule." in instructions
assert "Standard rule." not in instructions
def test_project_system_prompt_respects_disabled_agents_md(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
projects.create_project(
"demo",
{
"title": "Demo",
"include_agents_md": False,
},
)
project_root = tmp_path / "usr" / "projects" / "demo"
(project_root / "AGENTS.md").write_text("Root AGENTS rule.", encoding="utf-8")
prompt_vars = projects.build_system_prompt_vars("demo")
assert "Root AGENTS rule." not in prompt_vars["project_instructions"]
assert "AGENTS.md" not in prompt_vars["project_instructions"]
def test_agents_md_chain_walks_direct_path_only(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
root = tmp_path
(root / "AGENTS.md").write_text("root doc", encoding="utf-8")
target = root / "services" / "payments"
sibling = root / "services" / "auth"
target.mkdir(parents=True)
sibling.mkdir(parents=True)
(root / "services" / "AGENTS.md").write_text("services doc", encoding="utf-8")
(target / "AGENTS.md").write_text("payments doc", encoding="utf-8")
(sibling / "AGENTS.md").write_text("auth doc", encoding="utf-8")
chain = projects.get_agents_md_chain(str(root), str(target / "handler.py"))
contents = [content for _, content in chain]
assert contents == ["root doc", "services doc", "payments doc"]
def test_agents_md_protocol_excludes_project_root_and_keeps_subdir(
monkeypatch, tmp_path
):
_prepare_project_tree(monkeypatch, tmp_path)
prompt_name = "agent.protocol.projects.agents_md.md"
prompt_source = Path(__file__).resolve().parents[1] / "prompts" / prompt_name
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
(prompt_dir / prompt_name).write_text(
prompt_source.read_text(encoding="utf-8"),
encoding="utf-8",
)
projects.create_project("demo", {"title": "Demo"})
(tmp_path / "AGENTS.md").write_text("framework doc", encoding="utf-8")
project_root = tmp_path / "usr" / "projects" / "demo"
(project_root / "AGENTS.md").write_text("project root doc", encoding="utf-8")
api_dir = project_root / "api"
api_dir.mkdir()
(api_dir / "AGENTS.md").write_text("api doc", encoding="utf-8")
protocol = projects.build_agents_md_protocol(
"demo",
target=str(api_dir / "handler.py"),
)
assert "framework doc" in protocol
assert "api doc" in protocol
assert "project root doc" not in protocol