diff --git a/helpers/projects.py b/helpers/projects.py index b06d83008..baa14f91c 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -64,7 +64,6 @@ class EditProjectData(BasicProjectData): variables: str secrets: str mcp_servers: str - subagents: dict[str, SubAgentSettings] git_status: GitStatusData @@ -72,7 +71,7 @@ ProjectExtendedData = dict[str, object] _PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset( EditProjectData.__annotations__ ) -_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"}) +_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token", "subagents"}) def get_projects_parent_folder(): @@ -227,7 +226,6 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData: "file_structure", _default_file_structure_settings(), ), - "subagents": data.get("subagents", {}), } return normalized @@ -246,7 +244,6 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData: "knowledge_files_count": 0, "variables": "", "secrets": "", - "subagents": {}, "git_status": {"is_git_repo": False}, }, ) @@ -269,7 +266,6 @@ def update_project(name: str, data: EditProjectData): save_project_variables(name, current["variables"]) save_project_secrets(name, current["secrets"]) save_project_mcp_servers(name, current["mcp_servers"]) - save_project_subagents(name, current["subagents"]) save_project_extended_data(name, extended_data) reactivate_project_in_chats(name) @@ -291,7 +287,6 @@ def load_edit_project_data(name: str) -> EditProjectData: variables = load_project_variables(name) mcp_servers = load_project_mcp_servers(name) secrets = load_project_secrets_masked(name) - subagents = load_project_subagents(name) knowledge_files_count = get_knowledge_files_count(name) git_status = cast(GitStatusData, git.get_repo_status(get_project_folder(name))) @@ -305,7 +300,6 @@ def load_edit_project_data(name: str) -> EditProjectData: "variables": variables, "mcp_servers": mcp_servers, "secrets": secrets, - "subagents": subagents, "git_status": git_status, }, ) @@ -709,6 +703,40 @@ def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings files.write_file(abs_path, content) +def set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None: + from helpers import subagents + + name = validate_project_name(name) + if not os.path.isdir(get_project_folder(name)): + raise ValueError("Project not found.") + if not isinstance(enabled, bool): + raise ValueError("Agent availability must be true or false.") + agent = subagents.get_agents_dict(name).get(profile_id) + if not agent: + raise ValueError(f'Agent profile "{profile_id}" does not exist.') + + path = get_project_meta(name, "agents.json") + try: + settings = dirty_json.parse(files.read_file(path)) + except FileNotFoundError: + settings = {} + except Exception as exc: + raise ValueError("Project agent availability is invalid.") from exc + if not isinstance(settings, dict) or any( + not isinstance(key, str) + or not isinstance(value, dict) + or not isinstance(value.get("enabled"), bool) + for key, value in settings.items() + ): + raise ValueError("Project agent availability is invalid.") + + if agent.enabled == enabled: + settings.pop(profile_id, None) + else: + settings[profile_id] = {"enabled": enabled} + save_project_subagents(name, settings) + + def _normalize_subagents( subagents_data: dict[str, SubAgentSettings], project_name: str = "" ) -> dict[str, SubAgentSettings]: diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index 478e01d94..8c7a779df 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -58,6 +58,7 @@ - `_normalize_include_agents_md(value: object) -> bool` - `load_project_subagents(name: str) -> dict[str, SubAgentSettings]` - `save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings])` +- `set_project_subagent_enabled(name: str, profile_id: str, enabled: bool) -> None` - `_normalize_subagents(subagents_data: dict[str, SubAgentSettings], project_name: str=...) -> dict[str, SubAgentSettings]` - Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_SKILLS_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`. @@ -79,7 +80,11 @@ configured default profile, then `agent0`, then the first available profile. - Per-project profile availability is persisted sparsely in `.a0proj/agents.json`; entries matching the profile definition's scoped default are omitted. The - helper retains the established plain load/save contract for project settings. + helper retains the established tolerant load contract for read-only settings. + Profile-scoped mutations re-read the file strictly, preserve unrelated + entries, refuse malformed data, and write through `helpers.files`. General + project edit payloads neither expose nor mutate profile availability; legacy + `subagents` input is ignored. - Profile reconciliation treats `None` as the Global scope. Callers must pass `all_scopes=True` to check every loaded chat after a Global availability change. Each pass resolves the available profile catalog once per encountered diff --git a/helpers/settings.py b/helpers/settings.py index ac6ba0151..de7b50bb0 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -258,9 +258,12 @@ def convert_out(settings: Settings) -> SettingsOutput: chat_providers=get_providers("chat"), embedding_providers=get_providers("embedding"), is_dockerized=runtime.is_dockerized(), - agent_subdirs=[{"value": item["key"], "label": item["label"]} - for item in subagents.get_all_agents_list() - if item["key"] != "_example"], + agent_subdirs=[ + {"value": key, "label": item.title or key} + for key, item in sorted( + subagents.get_available_agents_dict(None).items() + ) + ], knowledge_subdirs=[{"value": subdir, "label": subdir} for subdir in files.get_subdirectories("knowledge", exclude="default")], timezones=_timezone_options(), @@ -284,7 +287,14 @@ def convert_out(settings: Settings) -> SettingsOutput: ), } - additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile")) + current_profile = current.get("agent_profile") + if current_profile and not any( + option["value"] == current_profile + for option in additional["agent_subdirs"] + ): + additional["agent_subdirs"].append( + {"value": current_profile, "label": f"{current_profile} (unavailable)"} + ) additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir")) if current.get("timezone") != TIMEZONE_AUTO: additional["timezones"] = _ensure_option_present(additional.get("timezones"), current.get("timezone")) diff --git a/helpers/settings.py.dox.md b/helpers/settings.py.dox.md index e34c06372..413f267f9 100644 --- a/helpers/settings.py.dox.md +++ b/helpers/settings.py.dox.md @@ -67,6 +67,9 @@ - Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change. - `max_consecutive_unusable_responses` defaults to `5` and controls the cost circuit breaker for malformed or repeated main-model outputs. - `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device. +- The Global default-profile selector lists only globally available profiles. + A currently configured unavailable profile remains visible with an explicit + unavailable label so settings can round-trip it truthfully. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/plugins/_agent_editor/AGENTS.md b/plugins/_agent_editor/AGENTS.md index 50d26c682..fcf491b9d 100644 --- a/plugins/_agent_editor/AGENTS.md +++ b/plugins/_agent_editor/AGENTS.md @@ -26,6 +26,11 @@ - Every profile, including `Default`, can be made unavailable. The backend rejects only the change that would leave the selected scope with no available profile, then reconciles loaded chats through the shared project owner. +- Destructive cleanup and custom-profile deletion require an explicit confirmed + apply request. Profile creation and availability invariants are rechecked at + the existing editor mutation boundary; project availability refuses malformed + `agents.json` and changes only the requested profile entry through the existing + project storage owner. - Never call `helpers.subagents.save_agent_data`. - Authored profile definitions remain YAML; editor-written plugin configs remain JSON. @@ -39,6 +44,8 @@ - Advanced prompt text is directly editable; per-file close/check actions discard or accept the current edit checkpoint, while the editor's global save remains the only persistence boundary. +- New profiles require a display name and non-empty agent instructions in both + Easy and Advanced; existing Advanced prompt edits retain per-file semantics. - The configurable tool catalog is visible in both modes; Easy provides direct allow/block checkboxes and points to Advanced for skill access. Skills remain Advanced-only. Advanced keeps both complete selectors visible but disabled diff --git a/plugins/_agent_editor/README.md b/plugins/_agent_editor/README.md index 160acda7e..588d56210 100644 --- a/plugins/_agent_editor/README.md +++ b/plugins/_agent_editor/README.md @@ -16,4 +16,4 @@ and can be removed without changing it. Manage agents can duplicate the effective profile into the selected scope and toggle whether each profile is available there. Project availability reuses `.a0proj/agents.json`; Global availability is a sparse profile override. -The Default profile always remains available. +The selected scope must always keep at least one profile available. diff --git a/plugins/_agent_editor/api/agent_editor.py b/plugins/_agent_editor/api/agent_editor.py index 936913633..462be8c00 100644 --- a/plugins/_agent_editor/api/agent_editor.py +++ b/plugins/_agent_editor/api/agent_editor.py @@ -41,20 +41,7 @@ class AgentEditor(ApiHandler): if not isinstance(enabled, bool): raise ValueError("Agent availability must be true or false.") if not enabled: - project_name = editor._context_project_name(context) - for live_context in AgentContext.all(): - if ( - getattr(live_context.config, "profile", "") == profile_id - and live_context.is_running() - and ( - not project_name - or projects.get_context_project_name(live_context) - == project_name - ) - ): - raise ValueError( - "This agent is running. Disable it after the run finishes." - ) + _reject_running_profile(profile_id, context, "Disable") receipt = editor.set_profile_enabled(profile_id, enabled, context) return { "ok": True, @@ -74,10 +61,21 @@ class AgentEditor(ApiHandler): } if action in {"plan_remove_changes", "remove_changes"}: profile_id = editor.validate_profile_id(input.get("profile_id")) + destructive = input.get("destructive", False) + if not isinstance(destructive, bool): + raise ValueError("Destructive removal must be true or false.") + if ( + action == "remove_changes" + and destructive + and input.get("confirm") is not True + ): + raise ValueError( + "Deleting all profile customizations requires confirmation." + ) plan = editor.plan_remove_changes( profile_id, context, - destructive=bool(input.get("destructive")), + destructive=destructive, ) if action == "plan_remove_changes": return {"ok": True, **plan.response()} @@ -89,6 +87,12 @@ class AgentEditor(ApiHandler): } if action in {"plan_delete", "delete"}: profile_id = editor.validate_profile_id(input.get("profile_id")) + if action == "delete": + if input.get("confirm") is not True: + raise ValueError( + "Deleting a custom agent requires confirmation." + ) + _reject_running_profile(profile_id, context, "Delete") plan = editor.plan_delete_custom(profile_id, context) if action == "plan_delete": return { @@ -96,8 +100,6 @@ class AgentEditor(ApiHandler): **plan.response(), "impact": editor.delete_impact(profile_id, context), } - if input.get("confirm") is not True: - raise ValueError("Deleting a custom agent requires confirmation.") receipt = editor.apply_change_plan(plan) project_name = editor._context_project_name(context) projects.reconcile_agent_profiles( @@ -115,15 +117,38 @@ def _context(input: dict[str, Any]) -> Any: context = AgentContext.get(context_id) if not context: raise ValueError("Chat context not found.") + _validate_project_scope(projects.get_context_project_name(context)) return context - project_name = str(input.get("project_name") or "").strip() - if project_name: - project_name = projects.validate_project_name(project_name) - if not Path(projects.get_project_folder(project_name)).is_dir(): - raise ValueError("Project not found.") + project_name = _validate_project_scope(input.get("project_name")) return editor._EditorContext(project_name) +def _validate_project_scope(project_name: Any) -> str: + value = str(project_name or "").strip() + if not value: + return "" + value = projects.validate_project_name(value) + if not Path(projects.get_project_folder(value)).is_dir(): + raise ValueError("Project not found.") + return value + + +def _reject_running_profile(profile_id: str, context: Any, action: str) -> None: + project_name = editor._context_project_name(context) + for live_context in AgentContext.all(): + if ( + getattr(live_context.config, "profile", "") == profile_id + and live_context.is_running() + and ( + not project_name + or projects.get_context_project_name(live_context) == project_name + ) + ): + raise ValueError( + f"This agent is running. {action} it after the run finishes." + ) + + def _active_profile(input: dict[str, Any]) -> dict[str, str]: context_id = str(input.get("active_context_id") or "").strip() context = AgentContext.get(context_id) if context_id else None diff --git a/plugins/_agent_editor/helpers/editor.py b/plugins/_agent_editor/helpers/editor.py index 840af0537..99ef10de9 100644 --- a/plugins/_agent_editor/helpers/editor.py +++ b/plugins/_agent_editor/helpers/editor.py @@ -82,6 +82,7 @@ class ChangePlan: staged_tokens: set[str] = field(default_factory=set) profile_id: str = "" project_name: str = "" + creating: bool = False remove_empty_root: bool = False def write(self, path: Path, content: str | bytes) -> None: @@ -681,7 +682,11 @@ def build_change_plan( if not creating and not exists: raise ValueError(f'Agent profile "{profile_id}" does not exist.') - plan = ChangePlan(profile_id=profile_id, project_name=project_name) + plan = ChangePlan( + profile_id=profile_id, + project_name=project_name, + creating=creating, + ) if "metadata" in patch: _plan_metadata(plan, patch["metadata"], context, creating=creating) if "prompts" in patch: @@ -758,25 +763,26 @@ def set_profile_enabled( if not profile_exists(profile_id, context): raise ValueError(f'Agent profile "{profile_id}" does not exist.') - available = subagents.get_available_agents_dict(project_name or None) - if not enabled and profile_id in available and len(available) == 1: - raise ValueError("At least one agent profile must remain available.") + with _MUTATION_LOCK: + available = subagents.get_available_agents_dict(project_name or None) + if not enabled and profile_id in available and len(available) == 1: + raise ValueError("At least one agent profile must remain available.") - if project_name: - settings = projects.load_project_subagents(project_name) - settings[profile_id] = {"enabled": enabled} - projects.save_project_subagents(project_name, settings) - receipt = { - "written": [ - _relative_source( - Path(projects.get_project_meta(project_name, "agents.json")) - ) - ], - "deleted": [], - "warnings": [], - } - else: - receipt = apply_change_plan(plan_profile_enabled(profile_id, enabled, context)) + if project_name: + projects.set_project_subagent_enabled(project_name, profile_id, enabled) + receipt = { + "written": [ + _relative_source( + Path(projects.get_project_meta(project_name, "agents.json")) + ) + ], + "deleted": [], + "warnings": [], + } + else: + receipt = apply_change_plan( + plan_profile_enabled(profile_id, enabled, context) + ) if not enabled: projects.reconcile_agent_profiles( @@ -807,7 +813,11 @@ def plan_duplicate_profile( target_title = f"{source_title} {index}" project_name = _context_project_name(context) target_root = _profile_root(target_id, project_name) - plan = ChangePlan(profile_id=target_id, project_name=project_name) + plan = ChangePlan( + profile_id=target_id, + project_name=project_name, + creating=True, + ) metadata: dict[str, Any] = {} for layer in _metadata_layers(profile_id, context): @@ -1128,6 +1138,13 @@ def apply_change_plan(plan: ChangePlan) -> dict[str, Any]: receipt = plan.response() with _MUTATION_LOCK: + if plan.creating and profile_exists( + plan.profile_id, + _EditorContext(project_name), + ): + raise ValueError( + f'Agent profile "{plan.profile_id}" was created before this save completed.' + ) snapshots = { change.path: change.path.read_bytes() if change.path.is_file() else None for change in changes @@ -1293,6 +1310,8 @@ def stage_avatar(upload: Any) -> dict[str, Any]: raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc except Image.DecompressionBombError as exc: raise ValueError("Avatar dimensions are too large.") from exc + except OSError as exc: + raise ValueError("Avatar must be a valid PNG, JPEG, or WebP image.") from exc _cleanup_staged_avatars() STAGED_AVATAR_ROOT.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_agent_editor.py b/tests/test_agent_editor.py index 9d05fe549..95339a3f2 100644 --- a/tests/test_agent_editor.py +++ b/tests/test_agent_editor.py @@ -4,6 +4,7 @@ from io import BytesIO import json from pathlib import Path import stat +from types import SimpleNamespace import pytest from werkzeug.datastructures import FileStorage @@ -480,11 +481,20 @@ def test_project_customizations_inherit_global_agent_and_remove_only_project_fil 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") @@ -927,6 +937,154 @@ def test_avatar_is_normalized_and_avatar_only_edit_is_sparse(user_root: Path) -> 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" + ) + }, + ) + configured = settings.get_default_settings().copy() + configured["agent_profile"] = "disabled-profile" + + options = settings.convert_out(configured)["additional"]["agent_subdirs"] + + assert options == [ + {"value": "default", "label": "Default"}, + { + "value": "disabled-profile", + "label": "disabled-profile (unavailable)", + }, + ] + + @pytest.mark.parametrize( ("relative_path", "patch", "label"), ( diff --git a/tests/test_projects.py b/tests/test_projects.py index 242e4227c..8b45eeb2b 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -107,6 +107,79 @@ def test_project_agent_availability_retains_project_only_profiles( ) == {"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) diff --git a/webui/components/projects/AGENTS.md b/webui/components/projects/AGENTS.md index d25375d1b..cf4bd161e 100644 --- a/webui/components/projects/AGENTS.md +++ b/webui/components/projects/AGENTS.md @@ -17,6 +17,9 @@ - Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily. - Preserve scoped settings interactions with plugins, models, skills, and MCP servers. - Project model settings select a global `_model_config` preset; they do not own copied model dictionaries or project-local preset definitions. +- Agent Editor owns profile-scoped availability mutations. Project edit payloads + do not include that state, so unrelated project saves cannot restore stale + toggle values. ## Work Guidance