main
py 1,289 lines 42.8 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 from io import BytesIO
5 import json
6 from pathlib import Path
7 import stat
8 from types import SimpleNamespace
9
10 import pytest
11 from werkzeug.datastructures import FileStorage
12
13 from helpers import yaml as yaml_helper
14 from plugins._agent_editor.api.agent_editor import AgentEditor
15 from plugins._agent_editor.api.agent_editor import _context as editor_context
16 from plugins._agent_editor.api.agent_editor_avatar import AgentEditorAvatar
17 from plugins._agent_editor.helpers import editor
18
19
20 @pytest.fixture
21 def user_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
22 root = tmp_path / "usr" / "agents"
23 real_determine_path = editor.plugins.determine_plugin_asset_path
24
25 def determine_plugin_asset_path(
26 plugin_name: str,
27 project_name: str,
28 profile_id: str,
29 *parts: str,
30 ) -> str:
31 if profile_id and not project_name:
32 return str(
33 root
34 / profile_id
35 / editor.files.PLUGINS_DIR
36 / plugin_name
37 / Path(*parts)
38 )
39 return real_determine_path(plugin_name, project_name, profile_id, *parts)
40
41 monkeypatch.setattr(editor, "USER_AGENTS_ROOT", root)
42 monkeypatch.setattr(editor, "STAGED_AVATAR_ROOT", tmp_path / "staged")
43 monkeypatch.setattr(
44 editor.plugins,
45 "determine_plugin_asset_path",
46 determine_plugin_asset_path,
47 )
48 monkeypatch.setattr(editor.plugins, "clear_plugin_cache", lambda _names: None)
49 return root
50
51
52 @pytest.fixture
53 def project_scope(
54 user_root: Path,
55 tmp_path: Path,
56 monkeypatch: pytest.MonkeyPatch,
57 ) -> tuple[editor._EditorContext, Path]:
58 project_folder = tmp_path / "usr" / "projects" / "demo"
59 project_meta = project_folder / ".a0proj"
60 project_meta.mkdir(parents=True)
61 real_folder = editor.projects.get_project_folder
62 real_meta = editor.projects.get_project_meta
63 monkeypatch.setattr(
64 editor.projects,
65 "get_project_folder",
66 lambda name: str(project_folder) if name == "demo" else real_folder(name),
67 )
68 monkeypatch.setattr(
69 editor.projects,
70 "get_project_meta",
71 lambda name, *parts: (
72 str(project_meta.joinpath(*parts))
73 if name == "demo"
74 else real_meta(name, *parts)
75 ),
76 )
77 return editor._EditorContext("demo"), project_meta / "agents"
78
79
80 def _write_manual_files(root: Path) -> dict[Path, bytes]:
81 manual_files = {
82 root / "prompts" / "manual.md": b"prompt",
83 root / "tools" / "manual.py": b"tool",
84 root / "extensions" / "manual.py": b"extension",
85 root / "skills" / "manual" / "SKILL.md": b"skill",
86 root / "assets" / "manual.bin": b"asset",
87 root / "plugins" / "manual" / "config.json": b"{}",
88 root / "unknown.bin": b"unknown",
89 }
90 for path, payload in manual_files.items():
91 path.parent.mkdir(parents=True, exist_ok=True)
92 path.write_bytes(payload)
93 return manual_files
94
95
96 def test_new_easy_profile_writes_only_minimum_exact_files(user_root: Path) -> None:
97 instructions = "Keep this exact. \n\nNo rewrite."
98 plan = editor.build_change_plan(
99 {
100 "profile_id": "legal-research",
101 "creating": True,
102 "editor_mode": "easy",
103 "metadata": {"set": {"title": "Legal Research"}, "reset": []},
104 "prompts": {
105 "set": {editor.SPECIFICS_FILE: instructions},
106 "reset": [],
107 },
108 "tool_policy": {"mode": "inherit"},
109 }
110 )
111
112 assert {path.relative_to(user_root).as_posix() for path in plan.changes} == {
113 "legal-research/agent.yaml",
114 f"legal-research/prompts/{editor.SPECIFICS_FILE}",
115 }
116 editor.apply_change_plan(plan)
117
118 profile = user_root / "legal-research"
119 assert yaml_helper.loads((profile / "agent.yaml").read_text()) == {
120 "title": "Legal Research"
121 }
122 assert (profile / "prompts" / editor.SPECIFICS_FILE).read_text() == instructions
123 assert not list(profile.rglob("*.json"))
124 assert stat.S_IMODE((profile / "agent.yaml").stat().st_mode) == 0o644
125 assert (
126 stat.S_IMODE((profile / "prompts" / editor.SPECIFICS_FILE).stat().st_mode)
127 == 0o644
128 )
129
130
131 def test_quick_create_uses_the_easy_sparse_save_path(user_root: Path) -> None:
132 profile_id, receipt = editor.save_easy_profile(
133 "Café Research",
134 "Verify sources and return concise citations.",
135 )
136
137 assert profile_id == "cafe-research"
138 assert len(receipt["written"]) == 2
139 assert yaml_helper.loads(
140 (user_root / profile_id / "agent.yaml").read_text(encoding="utf-8")
141 ) == {"title": "Café Research"}
142 assert not list((user_root / profile_id).rglob("*.json"))
143
144
145 def test_editor_lifecycle_needs_no_model_or_utility_configuration(
146 user_root: Path,
147 monkeypatch: pytest.MonkeyPatch,
148 ) -> None:
149 import litellm
150 from plugins._model_config.helpers import model_config
151
152 def forbidden(*_args, **_kwargs):
153 raise AssertionError("the Agent Editor attempted a model request")
154
155 for name in ("completion", "acompletion", "embedding", "aembedding"):
156 monkeypatch.setattr(litellm, name, forbidden, raising=False)
157 monkeypatch.setattr(model_config, "get_presets", lambda: [{"name": "No utility"}])
158 monkeypatch.setattr(
159 model_config,
160 "resolve_config_settings",
161 lambda _settings: {
162 "chat_model": {"provider": "offline", "name": "main"},
163 "utility_model": {},
164 "embedding_model": {},
165 },
166 )
167 monkeypatch.setattr(
168 model_config,
169 "get_configured_preset_name",
170 lambda **_kwargs: "No utility",
171 )
172 monkeypatch.setattr(editor.tool_policy, "get_tool_catalog", lambda _agent: [])
173 monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
174
175 state = editor.build_editor_state("offline-editor")
176 plan = editor.build_change_plan(
177 {
178 "profile_id": "offline-editor",
179 "creating": True,
180 "editor_mode": "easy",
181 "metadata": {"set": {"title": "Offline Editor"}, "reset": []},
182 "prompts": {"set": {editor.SPECIFICS_FILE: "Exact text."}, "reset": []},
183 }
184 )
185 receipt = plan.response()
186
187 assert state["model_presets"][0]["utility"] == {"provider": "", "name": ""}
188 assert editor.apply_change_plan(plan) == receipt
189
190
191 def test_state_catalog_is_complete_truthful_and_omits_internal_tools() -> None:
192 state = editor.build_editor_state("researcher")
193
194 assert all(
195 preset[slot]["provider"] and preset[slot]["name"]
196 for preset in state["model_presets"]
197 for slot in ("main", "utility", "embedding")
198 )
199 assert {prompt["group"] for prompt in state["prompts"]} == {
200 f"2.{index}" for index in range(1, 11)
201 }
202 assert all(
203 {
204 "effective",
205 "override",
206 "has_override",
207 "source",
208 "source_chain",
209 "state",
210 }.issubset(prompt)
211 for prompt in state["prompts"]
212 )
213 assert "AGENTS.md" not in {prompt["filename"] for prompt in state["prompts"]}
214 specifics = next(
215 prompt for prompt in state["prompts"]
216 if prompt["filename"] == editor.SPECIFICS_FILE
217 )
218 assert specifics["source_chain"] == ["Framework", "Researcher"]
219 assert any(
220 any(source.startswith("Plugin ·") for source in prompt["source_chain"])
221 for prompt in state["prompts"]
222 )
223 assert not {
224 item["name"] for item in state["tools"]["catalog"]
225 }.intersection({"response", "vision_load"})
226
227
228 def test_builtin_prompt_override_never_touches_bundled_profile(user_root: Path) -> None:
229 bundled = Path("agents/researcher")
230 before = {path: path.read_bytes() for path in bundled.rglob("*") if path.is_file()}
231 content = "Only the user-layer instructions change."
232 plan = editor.build_change_plan(
233 {
234 "profile_id": "researcher",
235 "prompts": {
236 "set": {editor.SPECIFICS_FILE: content},
237 "reset": [],
238 },
239 }
240 )
241
242 assert list(plan.changes) == [
243 user_root / "researcher" / "prompts" / editor.SPECIFICS_FILE
244 ]
245 editor.apply_change_plan(plan)
246 assert all(path.read_bytes() == payload for path, payload in before.items())
247
248 reset = editor.build_change_plan(
249 {
250 "profile_id": "researcher",
251 "prompts": {"set": {}, "reset": [editor.SPECIFICS_FILE]},
252 }
253 )
254 editor.apply_change_plan(reset)
255 assert not (user_root / "researcher" / "prompts" / editor.SPECIFICS_FILE).exists()
256
257
258 def test_unrelated_empty_user_directory_survives_save(user_root: Path) -> None:
259 manual = user_root / "researcher" / "tools" / "reserved-for-manual-use"
260 manual.mkdir(parents=True)
261
262 plan = editor.build_change_plan(
263 {
264 "profile_id": "researcher",
265 "prompts": {
266 "set": {editor.SPECIFICS_FILE: "Sparse change only."},
267 "reset": [],
268 },
269 }
270 )
271 editor.apply_change_plan(plan)
272
273 assert manual.is_dir()
274
275
276 def test_profile_collision_includes_disabled_plugin_profiles(
277 user_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278 ) -> None:
279 plugin_profile = tmp_path / "disabled-plugin" / "agents" / "reserved-agent"
280 plugin_profile.mkdir(parents=True)
281 monkeypatch.setattr(
282 editor.plugins,
283 "get_plugin_paths",
284 lambda *parts: [str(plugin_profile)] if parts == ("agents", "reserved-agent") else [],
285 )
286
287 assert editor.profile_exists("reserved-agent") is True
288
289
290 def test_metadata_empty_values_and_unknown_keys_survive(user_root: Path) -> None:
291 profile = user_root / "researcher"
292 profile.mkdir(parents=True)
293 metadata = profile / "agent.yaml"
294 metadata.write_text("custom_key: keep\ndescription: old\n", encoding="utf-8")
295
296 plan = editor.build_change_plan(
297 {
298 "profile_id": "researcher",
299 "metadata": {
300 "set": {"description": "", "context": ""},
301 "reset": [],
302 },
303 }
304 )
305 editor.apply_change_plan(plan)
306
307 saved = yaml_helper.loads(metadata.read_text())
308 assert saved == {"custom_key": "keep", "description": "", "context": ""}
309
310
311 def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> None:
312 profile = user_root / "researcher" / "plugins"
313 model = profile / "_model_config" / "config.json"
314 tools = profile / "_tool_access" / "config.json"
315 skill = profile / "_skills" / "config.json"
316 for path, value in (
317 (model, {"manual": 1}),
318 (tools, {"manual": 2}),
319 (skill, {"active_skills": [{"name": "existing"}]}),
320 ):
321 path.parent.mkdir(parents=True, exist_ok=True)
322 path.write_text(json.dumps(value), encoding="utf-8")
323
324 preset = editor.build_editor_state("researcher")["model_presets"][0]["name"]
325 plan = editor.build_change_plan(
326 {
327 "profile_id": "researcher",
328 "model_preset": {"mode": "preset", "name": preset},
329 "tool_policy": {
330 "mode": "custom",
331 "default": "block",
332 "mcp_default": "allow",
333 "allowed": ["local:search_engine"],
334 "blocked": ["local:shell"],
335 },
336 "skill_policy": {
337 "mode": "custom",
338 "default": "block",
339 "allowed": ["a0-development"],
340 "blocked": [],
341 },
342 }
343 )
344 editor.apply_change_plan(plan)
345
346 assert json.loads(model.read_text())["manual"] == 1
347 assert set(json.loads(tools.read_text())) >= {
348 "manual", "mode", "default", "mcp_default", "allowed", "blocked"
349 }
350 skill_data = json.loads(skill.read_text())
351 assert skill_data["active_skills"] == [{"name": "existing"}]
352 assert skill_data["visibility_policy"]["default"] == "block"
353
354 editor.apply_change_plan(editor.plan_remove_changes("researcher"))
355
356 assert json.loads(model.read_text()) == {"manual": 1}
357 assert json.loads(tools.read_text()) == {"manual": 2}
358 assert json.loads(skill.read_text()) == {
359 "active_skills": [{"name": "existing"}]
360 }
361 assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
362
363
364 def test_model_and_off_tool_choices_write_only_their_json_contracts(
365 user_root: Path,
366 ) -> None:
367 preset = editor.build_editor_state("researcher")["model_presets"][1]["name"]
368 model_path = user_root / "researcher" / "plugins" / "_model_config" / "config.json"
369 tool_path = user_root / "researcher" / "plugins" / "_tool_access" / "config.json"
370
371 inherit = editor.build_change_plan(
372 {"profile_id": "researcher", "model_preset": {"mode": "inherit"}}
373 )
374 assert inherit.changes == {}
375
376 selected = editor.build_change_plan(
377 {
378 "profile_id": "researcher",
379 "model_preset": {"mode": "preset", "name": preset},
380 }
381 )
382 assert list(selected.changes) == [model_path]
383 assert json.loads(selected.changes[model_path].content) == {"model_preset": preset}
384
385 off = editor.build_change_plan(
386 {"profile_id": "researcher", "tool_policy": {"mode": "off"}}
387 )
388 assert list(off.changes) == [tool_path]
389 assert json.loads(off.changes[tool_path].content) == {
390 "mode": "custom",
391 "default": "block",
392 "mcp_default": "block",
393 "allowed": [],
394 "blocked": [],
395 }
396
397
398 def test_profile_summaries_do_not_build_removal_plans(
399 user_root: Path,
400 monkeypatch: pytest.MonkeyPatch,
401 ) -> None:
402 profile = user_root / "researcher"
403 (profile / "plugins" / "manual").mkdir(parents=True)
404 (profile / "plugins" / "manual" / "config.json").write_text("{}")
405 monkeypatch.setattr(
406 editor,
407 "plan_remove_changes",
408 lambda *_args, **_kwargs: pytest.fail("profile summaries built a removal plan"),
409 )
410
411 assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
412 assert next(
413 item for item in editor.list_profiles() if item["id"] == "researcher"
414 )["scope_has_overrides"] is False
415
416 prompts = profile / "prompts"
417 prompts.mkdir()
418 (prompts / editor.SPECIFICS_FILE).write_text("Scoped instructions")
419 assert editor.build_profile_state("researcher")["scope_has_overrides"] is True
420
421
422 def test_project_tool_policy_reads_effective_access_and_writes_project_scope(
423 project_scope: tuple[editor._EditorContext, Path],
424 monkeypatch: pytest.MonkeyPatch,
425 ) -> None:
426 monkeypatch.setattr(editor.tool_policy, "get_tool_catalog", lambda _agent: [])
427 effective_policy = {
428 "mode": "custom",
429 "default": "allow",
430 "allowed": [],
431 "blocked": ["local:shell"],
432 }
433 monkeypatch.setattr(editor.tool_policy, "get_policy", lambda _agent: effective_policy)
434 monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
435 context, project_agents = project_scope
436
437 state = editor.build_editor_state("researcher", context)
438 plan = editor.build_change_plan(
439 {
440 "profile_id": "researcher",
441 "tool_policy": {"mode": "off"},
442 },
443 context,
444 )
445
446 assert state["tools"]["has_override"] is False
447 assert state["tools"]["effective_policy"] == effective_policy
448 assert list(plan.changes) == [
449 project_agents
450 / "researcher"
451 / "plugins"
452 / "_tool_access"
453 / "config.json"
454 ]
455
456
457 def test_tri_state_tool_mcp_and_skill_policies_write_at_both_scopes(
458 user_root: Path,
459 project_scope: tuple[editor._EditorContext, Path],
460 ) -> None:
461 tool_policy = {
462 "mode": "custom",
463 "default": "block",
464 "mcp_default": "allow",
465 "allowed": ["local:shell"],
466 "blocked": ["mcp:docs:write"],
467 }
468 skill_policy = {
469 "mode": "custom",
470 "default": "allow",
471 "allowed": ["Research"],
472 "blocked": ["Unsafe"],
473 }
474 patch = {
475 "profile_id": "researcher",
476 "tool_policy": tool_policy,
477 "skill_policy": skill_policy,
478 }
479 context, project_agents = project_scope
480
481 for plan, root in (
482 (editor.build_change_plan(patch), user_root),
483 (editor.build_change_plan(patch, context), project_agents),
484 ):
485 editor.apply_change_plan(plan)
486 profile_root = root / "researcher" / "plugins"
487 assert json.loads(
488 (profile_root / "_tool_access" / "config.json").read_text()
489 ) == tool_policy
490 assert json.loads(
491 (profile_root / "_skills" / "config.json").read_text()
492 )["visibility_policy"] == skill_policy
493
494
495 def test_project_agents_are_scope_owned_and_never_leak_global_writes(
496 user_root: Path,
497 project_scope: tuple[editor._EditorContext, Path],
498 ) -> None:
499 context, project_agents = project_scope
500 profile_id = "project-helper"
501 plan = editor.build_change_plan(
502 {
503 "profile_id": profile_id,
504 "creating": True,
505 "editor_mode": "easy",
506 "metadata": {"set": {"title": "Project Helper"}, "reset": []},
507 "prompts": {
508 "set": {editor.SPECIFICS_FILE: "Help only this project."},
509 "reset": [],
510 },
511 },
512 context,
513 )
514
515 assert plan.project_name == "demo"
516 assert all(path.is_relative_to(project_agents / profile_id) for path in plan.changes)
517 assert not (user_root / profile_id).exists()
518 editor.apply_change_plan(plan)
519
520 state = editor.build_profile_state(profile_id, context)
521 assert state["scope_has_overrides"] is True
522 assert state["deletable"] is True
523 assert any(item["id"] == profile_id for item in editor.list_profiles(context))
524 assert all(item["id"] != profile_id for item in editor.list_profiles())
525
526 delete = editor.plan_delete_custom(profile_id, context)
527 assert delete.project_name == "demo"
528 editor.apply_change_plan(delete)
529 assert not (project_agents / profile_id).exists()
530
531
532 def test_project_customizations_inherit_global_agent_and_remove_only_project_files(
533 user_root: Path,
534 project_scope: tuple[editor._EditorContext, Path],
535 ) -> None:
536 context, project_agents = project_scope
537 global_profile = user_root / "shared-helper"
538 global_profile.mkdir(parents=True)
539 (global_profile / "agent.yaml").write_text(
540 "title: Shared Helper\ndescription: Global description\n",
541 encoding="utf-8",
542 )
543
544 inherited = editor.build_profile_state("shared-helper", context)
545 assert inherited["metadata"]["description"]["effective"] == "Global description"
546 assert inherited["scope_has_overrides"] is False
547 assert inherited["deletable"] is False
548 with pytest.raises(ValueError, match="created in this scope"):
549 editor.plan_delete_custom("shared-helper", context)
550
551 plan = editor.build_change_plan(
552 {
553 "profile_id": "shared-helper",
554 "metadata": {"set": {"description": "Project description"}, "reset": []},
555 },
556 context,
557 )
558 project_yaml = project_agents / "shared-helper" / "agent.yaml"
559 assert list(plan.changes) == [project_yaml]
560 editor.apply_change_plan(plan)
561 assert editor.build_profile_state("shared-helper", context)["deletable"] is False
562
563 reset = editor.plan_remove_changes("shared-helper", context)
564 assert list(reset.changes) == [project_yaml]
565 editor.apply_change_plan(reset)
566 assert yaml_helper.loads((global_profile / "agent.yaml").read_text())["description"] == "Global description"
567
568
569 def test_project_scope_is_validated_at_api_and_apply_boundaries(
570 user_root: Path,
571 project_scope: tuple[editor._EditorContext, Path],
572 monkeypatch: pytest.MonkeyPatch,
573 ) -> None:
574 context, _project_agents = project_scope
575 assert editor.projects.get_context_project_name(editor_context({"project_name": "demo"})) == "demo"
576 with pytest.raises(ValueError, match="Project not found"):
577 editor_context({"project_name": "missing-agent-editor-project"})
578 monkeypatch.setattr(
579 "plugins._agent_editor.api.agent_editor.AgentContext.get",
580 lambda _context_id: SimpleNamespace(
581 get_data=lambda _key, recursive=True: "../outside"
582 ),
583 )
584 with pytest.raises(ValueError, match="Invalid project name"):
585 editor_context({"context_id": "unsafe-project-context"})
586
587 forged = editor.ChangePlan(profile_id="researcher", project_name="demo")
588 forged.write(user_root / "researcher" / "agent.yaml", "title: Wrong scope\n")
589 with pytest.raises(ValueError, match="outside the selected profile directory"):
590 editor.apply_change_plan(forged)
591
592
593 def test_unavailable_skill_policy_ids_are_retained_in_editor_state(
594 user_root: Path,
595 monkeypatch: pytest.MonkeyPatch,
596 ) -> None:
597 config = user_root / "researcher" / "plugins" / "_skills" / "config.json"
598 config.parent.mkdir(parents=True)
599 config.write_text(
600 json.dumps(
601 {
602 "visibility_policy": {
603 "mode": "custom",
604 "default": "allow",
605 "allowed": [],
606 "blocked": ["removed-skill"],
607 }
608 }
609 ),
610 encoding="utf-8",
611 )
612 monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
613 monkeypatch.setattr(
614 editor.skills,
615 "get_visibility_policy",
616 lambda _agent: {
617 "mode": "custom",
618 "default": "allow",
619 "allowed": [],
620 "blocked": ["removed-skill"],
621 },
622 )
623
624 state = editor.build_editor_state("researcher")
625
626 assert state["skills"]["policy"]["blocked"] == ["removed-skill"]
627 assert state["skills"]["catalog"] == [
628 {
629 "name": "removed-skill",
630 "description": "",
631 "path": "removed-skill",
632 "origin": "Unavailable",
633 "hidden": True,
634 "tags": [],
635 "allowed_tools": [],
636 "available": False,
637 }
638 ]
639
640
641 def test_display_title_change_keeps_profile_id_and_builtin_delete_is_rejected(
642 user_root: Path,
643 ) -> None:
644 with pytest.raises(ValueError, match="created in this scope"):
645 editor.plan_delete_custom("researcher")
646
647 plan = editor.build_change_plan(
648 {
649 "profile_id": "researcher",
650 "metadata": {"set": {"title": "Renamed Display"}, "reset": []},
651 }
652 )
653 assert list(plan.changes) == [user_root / "researcher" / "agent.yaml"]
654 editor.apply_change_plan(plan)
655
656 assert (user_root / "researcher" / "agent.yaml").is_file()
657 assert not (user_root / "renamed-display").exists()
658
659
660 def test_profile_availability_is_a_sparse_global_override(user_root: Path) -> None:
661 disabled = editor.plan_profile_enabled("researcher", False)
662
663 assert list(disabled.changes) == [user_root / "researcher" / "agent.yaml"]
664 editor.apply_change_plan(disabled)
665 assert yaml_helper.loads(
666 (user_root / "researcher" / "agent.yaml").read_text(encoding="utf-8")
667 ) == {"enabled": False}
668
669 restored = editor.plan_profile_enabled("researcher", True)
670 editor.apply_change_plan(restored)
671 assert not (user_root / "researcher" / "agent.yaml").exists()
672
673
674 def test_default_profile_can_be_disabled_when_another_profile_is_available(
675 user_root: Path,
676 project_scope: tuple[editor._EditorContext, Path],
677 ) -> None:
678 context, _ = project_scope
679
680 editor.set_profile_enabled("default", False)
681 assert yaml_helper.loads(
682 (user_root / "default" / "agent.yaml").read_text(encoding="utf-8")
683 ) == {"enabled": False}
684 editor.set_profile_enabled("default", True)
685 assert not (user_root / "default" / "agent.yaml").exists()
686
687 editor.set_profile_enabled("default", False, context)
688 assert editor.projects.load_project_subagents("demo") == {
689 "default": {"enabled": False}
690 }
691
692
693 def test_last_available_profile_cannot_be_disabled(
694 user_root: Path, monkeypatch: pytest.MonkeyPatch
695 ) -> None:
696 monkeypatch.setattr(
697 editor.subagents,
698 "get_available_agents_dict",
699 lambda _project=None: {
700 "default": editor.subagents.SubAgentListItem(name="default")
701 },
702 )
703
704 with pytest.raises(ValueError, match="At least one agent profile"):
705 editor.set_profile_enabled("default", False)
706
707 assert not (user_root / "default").exists()
708
709
710 def test_duplicate_profile_materializes_the_effective_profile(
711 user_root: Path,
712 ) -> None:
713 plan, title = editor.plan_duplicate_profile("developer")
714
715 assert plan.profile_id == "developer-1"
716 assert title == "Developer 1"
717 assert all(path.is_relative_to(user_root / "developer-1") for path in plan.changes)
718 editor.apply_change_plan(plan)
719
720 duplicate = user_root / "developer-1"
721 metadata = yaml_helper.loads(
722 (duplicate / "agent.yaml").read_text(encoding="utf-8")
723 )
724 assert metadata["title"] == "Developer 1"
725 assert metadata["description"] == "Agent specialized in complex software development."
726 assert "enabled" not in metadata
727 assert (duplicate / "prompts" / editor.SPECIFICS_FILE).read_bytes() == (
728 Path("agents/developer/prompts") / editor.SPECIFICS_FILE
729 ).read_bytes()
730 assert not (duplicate / "AGENTS.md").exists()
731
732 next_plan, next_title = editor.plan_duplicate_profile("developer")
733 assert next_plan.profile_id == "developer-2"
734 assert next_title == "Developer 2"
735
736
737 def test_duplicate_profile_targets_the_selected_project(
738 project_scope: tuple[editor._EditorContext, Path],
739 ) -> None:
740 context, project_agents = project_scope
741
742 plan, title = editor.plan_duplicate_profile("developer", context)
743
744 assert plan.project_name == "demo"
745 assert plan.profile_id == "developer-1"
746 assert title == "Developer 1"
747 assert all(
748 path.is_relative_to(project_agents / "developer-1")
749 for path in plan.changes
750 )
751
752
753 def test_project_profile_availability_uses_project_settings(
754 project_scope: tuple[editor._EditorContext, Path],
755 monkeypatch: pytest.MonkeyPatch,
756 ) -> None:
757 context, _ = project_scope
758 reconciled: list[tuple[tuple, dict]] = []
759 monkeypatch.setattr(
760 editor.projects,
761 "reconcile_agent_profiles",
762 lambda *args, **kwargs: reconciled.append((args, kwargs)),
763 )
764 monkeypatch.setattr(
765 editor.subagents,
766 "get_agents_dict",
767 lambda _project=None: {
768 "default": editor.subagents.SubAgentListItem(
769 name="default", enabled=True
770 ),
771 "researcher": editor.subagents.SubAgentListItem(
772 name="researcher", enabled=True
773 )
774 },
775 )
776
777 editor.set_profile_enabled("researcher", False, context)
778
779 assert editor.projects.load_project_subagents("demo") == {
780 "researcher": {"enabled": False}
781 }
782
783 editor.set_profile_enabled("researcher", True, context)
784
785 assert editor.projects.load_project_subagents("demo") == {}
786 assert reconciled == [(("demo",), {"all_scopes": False})]
787
788
789 def test_save_rolls_back_every_file_after_commit_failure(
790 user_root: Path,
791 monkeypatch: pytest.MonkeyPatch,
792 ) -> None:
793 root = user_root / "rollback-agent"
794 first = root / "agent.yaml"
795 second = root / "prompts" / editor.SPECIFICS_FILE
796 second.parent.mkdir(parents=True)
797 first.write_bytes(b"title: Before\n")
798 second.write_bytes(b"before")
799 plan = editor.ChangePlan(profile_id="rollback-agent")
800 plan.write(first, b"title: After\n")
801 plan.write(second, b"after")
802
803 original_replace = editor.os.replace
804 calls = 0
805 invalidations: list[bool] = []
806 monkeypatch.setattr(
807 editor,
808 "_invalidate_profile_caches",
809 lambda: invalidations.append(True),
810 )
811
812 def fail_second(source, destination):
813 nonlocal calls
814 calls += 1
815 if calls == 2:
816 raise OSError("simulated commit failure")
817 return original_replace(source, destination)
818
819 monkeypatch.setattr(editor.os, "replace", fail_second)
820 with pytest.raises(OSError, match="simulated"):
821 editor.apply_change_plan(plan)
822
823 assert first.read_bytes() == b"title: Before\n"
824 assert second.read_bytes() == b"before"
825 assert invalidations == []
826
827
828 def test_remove_my_changes_preserves_manual_and_unknown_files(
829 user_root: Path,
830 monkeypatch: pytest.MonkeyPatch,
831 ) -> None:
832 root = user_root / "researcher"
833 prompt = root / "prompts" / editor.SPECIFICS_FILE
834 manual_files = _write_manual_files(root)
835 prompt.parent.mkdir(parents=True, exist_ok=True)
836 prompt.write_text("override", encoding="utf-8")
837 (root / "agent.yaml").write_text(
838 "title: Mine\nunknown_key: keep\n", encoding="utf-8"
839 )
840 tool_config = root / "plugins" / "_tool_access" / "config.json"
841 tool_config.parent.mkdir(parents=True)
842 tool_config.write_text(
843 json.dumps({"mode": "custom", "default": "block", "manual": True}),
844 encoding="utf-8",
845 )
846
847 real_catalog = editor.prompt_catalog
848
849 def catalog(agent):
850 items = real_catalog(agent)
851 for item in items:
852 if item["filename"] == editor.SPECIFICS_FILE:
853 item.update({"has_override": True, "inherited_source": "agents/researcher"})
854 return items
855
856 monkeypatch.setattr(editor, "prompt_catalog", catalog)
857 plan = editor.plan_remove_changes("researcher")
858 assert set(manual_files).isdisjoint(plan.changes)
859 editor.apply_change_plan(plan)
860
861 assert all(path.read_bytes() == payload for path, payload in manual_files.items())
862 assert yaml_helper.loads((root / "agent.yaml").read_text()) == {
863 "unknown_key": "keep"
864 }
865 assert json.loads(tool_config.read_text()) == {"manual": True}
866
867
868 def test_destructive_cleanup_deletes_only_its_enumerated_plan(
869 user_root: Path,
870 ) -> None:
871 root = user_root / "researcher"
872 planned_files = _write_manual_files(root)
873 agent_yaml = root / "agent.yaml"
874 agent_yaml.write_text("title: Mine\n", encoding="utf-8")
875 planned_files[agent_yaml] = agent_yaml.read_bytes()
876
877 plan = editor.plan_remove_changes("researcher", destructive=True)
878 assert set(plan.changes) == set(planned_files)
879
880 unplanned = root / "created-after-plan.txt"
881 unplanned.write_text("keep", encoding="utf-8")
882 editor.apply_change_plan(plan)
883
884 assert all(not path.exists() for path in planned_files)
885 assert unplanned.read_text(encoding="utf-8") == "keep"
886
887
888 def test_mixed_save_matches_plan_preserves_every_unrelated_family_and_refreshes_cache(
889 user_root: Path,
890 monkeypatch: pytest.MonkeyPatch,
891 ) -> None:
892 root = user_root / "researcher"
893 manual_files = _write_manual_files(root)
894 preset = editor.build_editor_state("researcher")["model_presets"][1]["name"]
895 cleared: list[object] = []
896 monkeypatch.setattr(editor.cache, "clear", lambda area: cleared.append(area))
897 monkeypatch.setattr(
898 editor.plugins,
899 "clear_plugin_cache",
900 lambda names: cleared.append(tuple(names)),
901 )
902 plan = editor.build_change_plan(
903 {
904 "profile_id": "researcher",
905 "metadata": {"set": {"description": "Scoped"}, "reset": []},
906 "prompts": {
907 "set": {editor.SPECIFICS_FILE: "Only this prompt."},
908 "reset": [],
909 },
910 "model_preset": {"mode": "preset", "name": preset},
911 }
912 )
913 expected = plan.response()
914
915 assert {
916 path.relative_to(user_root).as_posix()
917 for path, change in plan.changes.items()
918 if change.action == "write"
919 } == {
920 "researcher/agent.yaml",
921 f"researcher/prompts/{editor.SPECIFICS_FILE}",
922 "researcher/plugins/_model_config/config.json",
923 }
924 assert editor.apply_change_plan(plan) == expected
925 assert cleared == [
926 editor.subagents.PATHS_CACHE_AREA,
927 ("_agent_editor", "_model_config", "_tool_access", "_skills"),
928 ]
929 assert all(path.read_bytes() == payload for path, payload in manual_files.items())
930
931
932 def test_empty_prompt_override_and_selected_project_scope_are_distinct(
933 user_root: Path,
934 tmp_path: Path,
935 monkeypatch: pytest.MonkeyPatch,
936 ) -> None:
937 framework = tmp_path / "framework"
938 user_prompts = user_root / "researcher" / "prompts"
939 project_meta = tmp_path / "project" / ".a0proj"
940 project_prompts = project_meta / "agents" / "researcher" / "prompts"
941 for path, text in (
942 (framework / editor.SPECIFICS_FILE, "framework"),
943 (user_prompts / editor.SPECIFICS_FILE, ""),
944 (project_prompts / editor.SPECIFICS_FILE, "project"),
945 ):
946 path.parent.mkdir(parents=True, exist_ok=True)
947 path.write_text(text, encoding="utf-8")
948 monkeypatch.setattr(
949 editor.subagents,
950 "get_paths",
951 lambda _agent, *parts: (
952 [str(user_prompts), str(framework)]
953 if not editor.projects.get_context_project_name(_agent.context)
954 else [str(project_prompts), str(user_prompts), str(framework)]
955 ),
956 )
957 original_meta = editor.projects.get_project_meta
958 monkeypatch.setattr(
959 editor.projects,
960 "get_project_meta",
961 lambda name, *parts: str(project_meta.joinpath(*parts))
962 if name == "acceptance-project"
963 else original_meta(name, *parts),
964 )
965
966 empty = next(
967 item
968 for item in editor.prompt_catalog(editor.EditorAgent("researcher"))
969 if item["filename"] == editor.SPECIFICS_FILE
970 )
971 project = next(
972 item
973 for item in editor.prompt_catalog(
974 editor.EditorAgent(
975 "researcher",
976 editor._EditorContext("acceptance-project"),
977 )
978 )
979 if item["filename"] == editor.SPECIFICS_FILE
980 )
981
982 assert empty["state"] == "Overridden here (empty)"
983 assert empty["has_override"] is True
984 assert empty["effective"] == ""
985 assert project["state"] == "Overridden here"
986 assert project["effective"] == "project"
987 assert project["source_chain"][-2:] == ["Global", "Your override"]
988
989 reset = editor.build_change_plan(
990 {
991 "profile_id": "researcher",
992 "prompts": {"set": {}, "reset": [editor.SPECIFICS_FILE]},
993 }
994 )
995 assert list(reset.changes) == [user_prompts / editor.SPECIFICS_FILE]
996 assert next(iter(reset.changes.values())).action == "delete"
997
998
999 def test_avatar_is_normalized_and_avatar_only_edit_is_sparse(user_root: Path) -> None:
1000 from PIL import Image
1001
1002 source = BytesIO()
1003 Image.new("RGB", (800, 400), "red").save(source, format="PNG")
1004 upload = FileStorage(stream=BytesIO(source.getvalue()), filename="avatar.png")
1005 staged = editor.stage_avatar(upload)
1006 plan = editor.build_change_plan(
1007 {
1008 "profile_id": "researcher",
1009 "metadata": {
1010 "set": {"avatar": {"kind": "image", "token": staged["token"]}},
1011 "reset": [],
1012 },
1013 }
1014 )
1015
1016 assert {path.relative_to(user_root).as_posix() for path in plan.changes} == {
1017 "researcher/agent.yaml",
1018 "researcher/assets/avatar.webp",
1019 }
1020 editor.apply_change_plan(plan)
1021 avatar = user_root / "researcher" / "assets" / "avatar.webp"
1022 with Image.open(avatar) as normalized:
1023 assert normalized.format == "WEBP"
1024 assert normalized.size == (editor.AVATAR_SIZE, editor.AVATAR_SIZE)
1025 assert not normalized.getexif()
1026
1027
1028 @pytest.mark.asyncio
1029 async def test_truncated_avatar_is_a_validation_error(user_root: Path) -> None:
1030 from PIL import Image
1031
1032 source = BytesIO()
1033 Image.new("RGB", (32, 32), "red").save(source, format="PNG")
1034 upload = FileStorage(
1035 stream=BytesIO(source.getvalue()[:-24]),
1036 filename="truncated.png",
1037 )
1038 response = await AgentEditorAvatar(None, None).process( # type: ignore[arg-type]
1039 {},
1040 SimpleNamespace(method="POST", files={"avatar": upload}),
1041 )
1042
1043 assert response.status_code == 400
1044 assert "valid PNG, JPEG, or WebP" in response.get_data(as_text=True)
1045
1046
1047 @pytest.mark.asyncio
1048 async def test_destructive_removal_requires_a_boolean_and_confirmation(
1049 user_root: Path,
1050 ) -> None:
1051 profile_root = user_root / "researcher"
1052 manual = profile_root / "manual.txt"
1053 manual.parent.mkdir(parents=True)
1054 manual.write_text("keep until confirmed", encoding="utf-8")
1055 handler = AgentEditor(None, None) # type: ignore[arg-type]
1056
1057 malformed = await handler.process(
1058 {
1059 "action": "remove_changes",
1060 "profile_id": "researcher",
1061 "destructive": "false",
1062 },
1063 None, # type: ignore[arg-type]
1064 )
1065 unconfirmed = await handler.process(
1066 {
1067 "action": "remove_changes",
1068 "profile_id": "researcher",
1069 "destructive": True,
1070 },
1071 None, # type: ignore[arg-type]
1072 )
1073
1074 assert malformed.status_code == 400
1075 assert unconfirmed.status_code == 400
1076 assert manual.read_text(encoding="utf-8") == "keep until confirmed"
1077
1078 applied = await handler.process(
1079 {
1080 "action": "remove_changes",
1081 "profile_id": "researcher",
1082 "destructive": True,
1083 "confirm": True,
1084 },
1085 None, # type: ignore[arg-type]
1086 )
1087 assert applied["ok"] is True
1088 assert not manual.exists()
1089
1090
1091 @pytest.mark.asyncio
1092 async def test_running_custom_profile_cannot_be_deleted(
1093 user_root: Path,
1094 monkeypatch: pytest.MonkeyPatch,
1095 ) -> None:
1096 profile_root = user_root / "running-custom"
1097 profile_root.mkdir(parents=True)
1098 (profile_root / "agent.yaml").write_text(
1099 "title: Running custom\n",
1100 encoding="utf-8",
1101 )
1102 running = SimpleNamespace(
1103 config=SimpleNamespace(profile="running-custom"),
1104 is_running=lambda: True,
1105 )
1106 monkeypatch.setattr(
1107 "plugins._agent_editor.api.agent_editor.AgentContext.all",
1108 lambda: [running],
1109 )
1110
1111 response = await AgentEditor(None, None).process( # type: ignore[arg-type]
1112 {
1113 "action": "delete",
1114 "profile_id": "running-custom",
1115 "confirm": True,
1116 },
1117 None, # type: ignore[arg-type]
1118 )
1119
1120 assert response.status_code == 400
1121 assert "running" in response.get_data(as_text=True)
1122 assert profile_root.is_dir()
1123
1124
1125 def test_stale_create_plan_cannot_overwrite_a_new_profile(user_root: Path) -> None:
1126 patch = {
1127 "profile_id": "create-race",
1128 "creating": True,
1129 "editor_mode": "easy",
1130 "metadata": {"set": {"title": "Create race"}, "reset": []},
1131 "prompts": {
1132 "set": {editor.SPECIFICS_FILE: "First writer wins."},
1133 "reset": [],
1134 },
1135 }
1136 first = editor.build_change_plan(patch)
1137 stale = editor.build_change_plan(patch)
1138
1139 editor.apply_change_plan(first)
1140 with pytest.raises(ValueError, match="created before this save completed"):
1141 editor.apply_change_plan(stale)
1142
1143 assert yaml_helper.loads(
1144 (user_root / "create-race" / "agent.yaml").read_text(encoding="utf-8")
1145 ) == {"title": "Create race"}
1146
1147
1148 def test_settings_default_profile_catalog_uses_global_availability(
1149 monkeypatch: pytest.MonkeyPatch,
1150 ) -> None:
1151 from helpers import settings
1152
1153 monkeypatch.setattr(
1154 settings.subagents,
1155 "get_available_agents_dict",
1156 lambda _project: {
1157 "default": settings.subagents.SubAgentListItem(
1158 name="default", title="Default"
1159 ),
1160 "agent0": settings.subagents.SubAgentListItem(
1161 name="agent0", title="Agent 0"
1162 ),
1163 },
1164 )
1165 configured = settings.get_default_settings().copy()
1166 configured["agent_profile"] = "disabled-profile"
1167
1168 options = settings.convert_out(configured)["additional"]["agent_subdirs"]
1169
1170 assert options == [
1171 {"value": "agent0", "label": "Agent 0"},
1172 {
1173 "value": "disabled-profile",
1174 "label": "disabled-profile (unavailable)",
1175 },
1176 ]
1177
1178
1179 def test_shared_profile_catalog_drives_generic_and_web_selectors(
1180 monkeypatch: pytest.MonkeyPatch,
1181 ) -> None:
1182 from api import agents
1183 from helpers import integration_commands, subagents
1184
1185 profiles = [{"key": "agent0", "label": "Agent 0"}]
1186 monkeypatch.setattr(subagents, "get_all_agents_list", lambda: profiles)
1187
1188 handler = agents.Agents.__new__(agents.Agents)
1189 response = asyncio.run(handler.process({"action": "list"}, None))
1190 assert [item["key"] for item in response["data"]] == ["agent0"]
1191
1192 context = SimpleNamespace(
1193 agent0=SimpleNamespace(config=SimpleNamespace(profile="default")),
1194 is_running=lambda: False,
1195 )
1196 status = integration_commands._handle_agent(context, "")
1197 assert "Current agent: default" in status
1198 assert "Agent 0 (agent0)" in status
1199 assert "Default (default)" not in status
1200 assert "was not found" in integration_commands._handle_agent(context, "default")
1201
1202
1203 @pytest.mark.parametrize(
1204 ("relative_path", "patch", "label"),
1205 (
1206 (
1207 "agent.yaml",
1208 {"metadata": {"set": {"description": "new"}, "reset": []}},
1209 "profile metadata",
1210 ),
1211 (
1212 "plugins/_tool_access/config.json",
1213 {"tool_policy": {"mode": "off"}},
1214 "tool policy configuration",
1215 ),
1216 ),
1217 )
1218 def test_invalid_existing_authored_files_are_never_overwritten(
1219 user_root: Path,
1220 relative_path: str,
1221 patch: dict,
1222 label: str,
1223 ) -> None:
1224 path = user_root / "researcher" / relative_path
1225 path.parent.mkdir(parents=True, exist_ok=True)
1226 original = b"{ definitely not valid\n"
1227 path.write_bytes(original)
1228
1229 with pytest.raises(ValueError, match=label):
1230 editor.build_change_plan({"profile_id": "researcher", **patch})
1231
1232 assert path.read_bytes() == original
1233
1234
1235 def test_editor_preview_raw_reads_markdown_without_running_dynamic_processor(
1236 tmp_path: Path,
1237 monkeypatch: pytest.MonkeyPatch,
1238 ) -> None:
1239 prompt_root = tmp_path / "prompts"
1240 prompt_root.mkdir()
1241 (prompt_root / editor.SPECIFICS_FILE).write_text(
1242 "Raw {{value}}", encoding="utf-8"
1243 )
1244 (prompt_root / "agent.system.main.specifics.py").write_text(
1245 "raise RuntimeError('must not run')\n",
1246 encoding="utf-8",
1247 )
1248 monkeypatch.setattr(
1249 editor.subagents,
1250 "get_paths",
1251 lambda *_args, **_kwargs: [str(prompt_root)],
1252 )
1253 monkeypatch.setattr(
1254 editor.files,
1255 "read_prompt_file",
1256 lambda *_args, **_kwargs: pytest.fail("dynamic prompt loader ran"),
1257 )
1258
1259 item = next(
1260 item
1261 for item in editor.prompt_catalog(editor.EditorAgent("researcher"))
1262 if item["filename"] == editor.SPECIFICS_FILE
1263 )
1264
1265 assert item["effective"] == "Raw {{value}}"
1266 assert item["preview"] == "Raw {{value}}"
1267 assert item["dynamic_processor"] is True
1268
1269
1270 def test_editor_api_keeps_default_auth_and_csrf_protection() -> None:
1271 for handler in (AgentEditor, AgentEditorAvatar):
1272 assert handler.requires_auth() is True
1273 assert handler.requires_csrf() is True
1274
1275
1276 def test_backend_has_no_legacy_save_or_model_request_path() -> None:
1277 sources = "\n".join(
1278 path.read_text(encoding="utf-8")
1279 for path in (
1280 Path(editor.__file__),
1281 Path("plugins/_agent_editor/api/agent_editor.py"),
1282 Path("plugins/_agent_editor/api/agent_editor_avatar.py"),
1283 )
1284 )
1285
1286 assert "save_agent_data" not in sources
1287 assert "call_llm" not in sources
1288 assert "call_utility_model" not in sources
1289 assert "litellm" not in sources