From 51f3ed00a15838d0d6186f7cfefe7b3d9a0d5f96 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:34:21 +0200 Subject: [PATCH] Add project extension data hooks Introduce generic project edit-data extension hooks so plugins can contribute named project settings sections without coupling helpers.projects to plugin-specific storage. Move _model_config project LLM payload handling behind those hooks and preserve inherited global model settings unless a project override already exists or is explicitly selected. Cover the inheritance regression, existing scoped overrides, multiple plugin sections, core-field collision protection, and extension payload filtering in focused project/model-config tests. --- helpers/projects.py | 89 +++--- helpers/projects.py.dox.md | 11 +- plugins/_model_config/AGENTS.md | 1 + .../end/_10_model_config.py | 14 + .../start/_10_model_config.py | 12 + plugins/_model_config/helpers/model_config.py | 70 +++++ tests/test_model_config_project_presets.py | 291 ++++++++++++++++++ 7 files changed, 438 insertions(+), 50 deletions(-) create mode 100644 plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py create mode 100644 plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py diff --git a/helpers/projects.py b/helpers/projects.py index 561c7ddb6..f146d02a6 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -1,7 +1,7 @@ import os from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast -from helpers import files, dirty_json, persist_chat, file_tree +from helpers import files, dirty_json, persist_chat, file_tree, extension from helpers.print_style import PrintStyle @@ -62,6 +62,12 @@ class EditProjectData(BasicProjectData): git_status: GitStatusData +ProjectExtendedData = dict[str, object] +_PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset( + EditProjectData.__annotations__ +) +_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"}) + def get_projects_parent_folder(): return files.get_abs_path(PROJECTS_PARENT_DIR) @@ -94,7 +100,7 @@ def delete_project(name: str): def create_project(name: str, data: BasicProjectData): - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" @@ -103,7 +109,7 @@ def create_project(name: str, data: BasicProjectData): data = _normalizeBasicData(data) save_project_header(name, data) save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG) - save_project_llm_settings(name, llm_data) + save_project_extended_data(name, extended_data) return name @@ -111,7 +117,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec """Clone a git repository as a new A0 project. Token is used only for cloning via http header.""" from helpers import git - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( @@ -142,7 +148,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec if mcp_servers: save_project_mcp_servers(actual_name, mcp_servers) - save_project_llm_settings(actual_name, llm_data) + save_project_extended_data(actual_name, extended_data) return actual_name except Exception as e: @@ -242,7 +248,7 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData: def update_project(name: str, data: EditProjectData): - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) # merge with current state current = load_edit_project_data(name) @@ -258,7 +264,7 @@ def update_project(name: str, data: EditProjectData): save_project_secrets(name, current["secrets"]) save_project_mcp_servers(name, current["mcp_servers"]) save_project_subagents(name, current["subagents"]) - save_project_llm_settings(name, llm_data) + save_project_extended_data(name, extended_data) reactivate_project_in_chats(name) return name @@ -298,7 +304,7 @@ def load_edit_project_data(name: str) -> EditProjectData: }, ) data = _normalizeEditData(data) - data["llm"] = load_project_llm_data(name) # type: ignore[typeddict-unknown-key] + _merge_project_extended_data(data, load_project_extended_data(name)) return data @@ -312,53 +318,42 @@ def save_project_header(name: str, data: BasicProjectData): files.write_file(abs_path, header) -def load_project_llm_data(name: str) -> dict: - from plugins._model_config.helpers import model_config +@extension.extensible +def load_project_extended_data(name: str) -> ProjectExtendedData: + return {} - config = model_config.normalize_config_for_save( - model_config.get_config(project_name=name) or {} - ) + +@extension.extensible +def save_project_extended_data(name: str, project_data: ProjectExtendedData): + return None + + +def _project_extended_data_for_save(data: object) -> ProjectExtendedData: + if not isinstance(data, dict): + return {} return { - "config": config, - "selected_preset": { - "scope": "current", - "project_name": name, - "name": "Current config", - }, - "presets": model_config.get_combined_presets(name), - "global_presets": model_config.get_presets(), - "project_presets": model_config.get_project_presets(name), + str(key): value + for key, value in data.items() + if str(key) not in _PROJECT_CORE_EDIT_KEYS + and str(key) not in _PROJECT_TRANSIENT_INPUT_KEYS } -def save_project_llm_settings(name: str, llm_data: object): - if not isinstance(llm_data, dict): +def _merge_project_extended_data( + data: EditProjectData, + extended_data: object, +) -> None: + if not isinstance(extended_data, dict): return - from helpers import plugins - from plugins._model_config.helpers import model_config + conflicts = sorted(str(key) for key in extended_data if key in _PROJECT_CORE_EDIT_KEYS) + if conflicts: + raise ValueError( + "Project extension data cannot overwrite core project fields: " + + ", ".join(conflicts) + ) - project_presets = llm_data.get("project_presets") - if isinstance(project_presets, list): - model_config.save_presets(project_presets, project_name=name) - - config_to_save = None - selected_preset = llm_data.get("selected_preset") - if isinstance(selected_preset, dict) and selected_preset.get("scope") in { - "global", - "project", - }: - preset = model_config.resolve_preset_selection(selected_preset, project_name=name) - if preset: - base_config = model_config.get_config(project_name=name) or {} - config_to_save = model_config.build_config_from_preset(preset, base_config) - - config = llm_data.get("config") - if config_to_save is None and isinstance(config, dict): - config_to_save = model_config.normalize_config_for_save(config) - - if config_to_save is not None: - plugins.save_plugin_config("_model_config", name, "", config_to_save) + data.update(extended_data) # type: ignore[typeddict-item] def load_project_mcp_servers(name: str) -> str: diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index 36d1cc298..aae7514d0 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -34,8 +34,10 @@ - `load_basic_project_data(name: str) -> BasicProjectData` - `load_edit_project_data(name: str) -> EditProjectData` - `save_project_header(name: str, data: BasicProjectData)` -- `load_project_llm_data(name: str) -> dict` -- `save_project_llm_settings(name: str, llm_data: object)` +- `load_project_extended_data(name: str) -> ProjectExtendedData` +- `save_project_extended_data(name: str, project_data: ProjectExtendedData)` +- `_project_extended_data_for_save(data: object) -> ProjectExtendedData` +- `_merge_project_extended_data(data: EditProjectData, extended_data: object) -> None` - `load_project_mcp_servers(name: str) -> str` - `save_project_mcp_servers(name: str, mcp_servers: str)` - `get_active_projects_list()` @@ -57,6 +59,9 @@ - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Per-project MCP server configuration is persisted as `.a0proj/mcp_servers.json`, exposed through `load_edit_project_data(...)`, and saved during project create/clone/update flows. +- Additional project-edit payload sections are delegated through extensible `load_project_extended_data(...)` and `save_project_extended_data(...)`; the project helper must stay storage-agnostic and plugin-specific config rules belong to the owning plugin. +- Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`. +- Project extension save payloads exclude core project fields and transient inputs such as `git_token`; plugins needing core metadata should load it by project name. - Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently. - Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`. - Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`. @@ -65,7 +70,7 @@ ## Key Concepts -- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`. +- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_extended_data`, `load_project_extended_data`, `_project_extended_data_for_save`, `_merge_project_extended_data`, `_PROJECT_CORE_EDIT_KEYS`, `_PROJECT_TRANSIENT_INPUT_KEYS`, `extension.extensible`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/plugins/_model_config/AGENTS.md b/plugins/_model_config/AGENTS.md index c1e5fa146..e263cb364 100644 --- a/plugins/_model_config/AGENTS.md +++ b/plugins/_model_config/AGENTS.md @@ -14,6 +14,7 @@ ## Local Contracts - Preserve global, project, agent, and chat override resolution order. +- Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules. - Keep provider metadata and API-key checks safe around secrets. - Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here. - Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers. diff --git a/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py new file mode 100644 index 000000000..a2dbe38ec --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py @@ -0,0 +1,14 @@ +from helpers.extension import Extension +from plugins._model_config.helpers import model_config + + +class ProjectLlmDataProvider(Extension): + def execute(self, data: dict = {}, **kwargs): + result = data.get("result") + if not isinstance(result, dict): + result = {} + data["result"] = result + + args = data.get("args") or () + project_name = args[0] if args else data.get("kwargs", {}).get("name", "") + result["llm"] = model_config.load_project_llm_data(str(project_name or "")) diff --git a/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py b/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py new file mode 100644 index 000000000..f4143afc3 --- /dev/null +++ b/plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py @@ -0,0 +1,12 @@ +from helpers.extension import Extension +from plugins._model_config.helpers import model_config + + +class ProjectLlmSettingsSaver(Extension): + def execute(self, data: dict = {}, **kwargs): + args = data.get("args") or () + call_kwargs = data.get("kwargs") or {} + project_name = args[0] if args else call_kwargs.get("name", "") + project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data") + llm_data = project_data.get("llm") if isinstance(project_data, dict) else None + model_config.save_project_llm_settings(str(project_name or ""), llm_data) diff --git a/plugins/_model_config/helpers/model_config.py b/plugins/_model_config/helpers/model_config.py index 66ed32b70..7632ad94f 100644 --- a/plugins/_model_config/helpers/model_config.py +++ b/plugins/_model_config/helpers/model_config.py @@ -99,6 +99,76 @@ def get_config(agent=None, project_name=None, agent_profile=None): ) or {} +def has_project_config(project_name: str) -> bool: + path = plugins.determine_plugin_asset_path( + "_model_config", project_name, "", plugins.CONFIG_FILE_NAME + ) + return files.exists(path) + + +def load_project_llm_data(project_name: str) -> dict: + """Build the model-settings payload shown in Project Settings.""" + project_config_exists = has_project_config(project_name) + config = normalize_config_for_save(get_config(project_name=project_name) or {}) + return { + "config": config, + "config_scope": "project" if project_config_exists else "inherited", + "has_project_config": project_config_exists, + "selected_preset": { + "scope": "current", + "project_name": project_name, + "name": "Current config", + }, + "presets": get_combined_presets(project_name), + "global_presets": get_presets(), + "project_presets": get_project_presets(project_name), + } + + +def save_project_llm_settings(project_name: str, llm_data: object) -> None: + """Persist Project Settings model data without freezing inherited globals.""" + if not isinstance(llm_data, dict): + return + + project_config_exists = has_project_config(project_name) + project_presets = llm_data.get("project_presets") + presets_path = _get_presets_path(project_name) + if isinstance(project_presets, list) and ( + project_presets or files.exists(presets_path) + ): + save_presets(project_presets, project_name=project_name) + + config_to_save = None + selected_preset = llm_data.get("selected_preset") + if isinstance(selected_preset, dict) and selected_preset.get("scope") in { + PRESET_SCOPE_GLOBAL, + PRESET_SCOPE_PROJECT, + }: + preset = resolve_preset_selection(selected_preset, project_name=project_name) + if preset: + base_config = get_config(project_name=project_name) or {} + config_to_save = build_config_from_preset(preset, base_config) + + config = llm_data.get("config") + config_scope = str(llm_data.get("config_scope") or "") + config_is_inherited = config_scope == "inherited" and not project_config_exists + should_save_config = ( + project_config_exists + or config_scope == "project" + or not config_scope + ) + if ( + config_to_save is None + and isinstance(config, dict) + and should_save_config + and not config_is_inherited + ): + config_to_save = normalize_config_for_save(config) + + if config_to_save is not None: + plugins.save_plugin_config("_model_config", project_name, "", config_to_save) + + def _load_presets_from_path(path: str) -> list | None: if files.exists(path): data = yaml_helper.loads(files.read_file(path)) diff --git a/tests/test_model_config_project_presets.py b/tests/test_model_config_project_presets.py index 571ae5563..a6d9793ab 100644 --- a/tests/test_model_config_project_presets.py +++ b/tests/test_model_config_project_presets.py @@ -48,6 +48,21 @@ sys.modules.setdefault("watchdog.observers", watchdog.observers) sys.modules.setdefault("watchdog.events", watchdog.events) +def _copy_extension_fixture(plugin_dir: Path, relative_path: str) -> None: + source = PROJECT_ROOT / "plugins" / "_model_config" / relative_path + target = plugin_dir / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + +def _clear_runtime_caches(): + from helpers import cache, modules + + cache.clear("*(extensions)*") + cache.clear("*(plugins)*") + modules.purge_namespace("usr.plugins") + + def _prepare_a0_tree(monkeypatch, tmp_path: Path): from helpers import files, plugins @@ -91,7 +106,140 @@ embedding_model: """.lstrip(), encoding="utf-8", ) + _copy_extension_fixture( + plugin_dir, + "extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py", + ) + _copy_extension_fixture( + plugin_dir, + "extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py", + ) + (tmp_path / "usr" / "plugins").mkdir(parents=True) (tmp_path / "usr" / "projects").mkdir(parents=True) + _clear_runtime_caches() + + +def _add_project_extra_plugin(tmp_path: Path): + plugin_dir = tmp_path / "plugins" / "_project_extra" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + "name: _project_extra\n", + encoding="utf-8", + ) + load_ext = ( + plugin_dir + / "extensions" + / "python" + / "_functions" + / "helpers" + / "projects" + / "load_project_extended_data" + / "end" + / "_20_project_extra.py" + ) + load_ext.parent.mkdir(parents=True, exist_ok=True) + load_ext.write_text( + """ +from helpers.extension import Extension + + +class ProjectExtraLoader(Extension): + def execute(self, data: dict = {}, **kwargs): + result = data.get("result") + if not isinstance(result, dict): + result = {} + data["result"] = result + args = data.get("args") or () + project_name = args[0] if args else data.get("kwargs", {}).get("name", "") + result["extra"] = {"loaded_for": str(project_name or ""), "enabled": True} +""".lstrip(), + encoding="utf-8", + ) + save_ext = ( + plugin_dir + / "extensions" + / "python" + / "_functions" + / "helpers" + / "projects" + / "save_project_extended_data" + / "start" + / "_20_project_extra.py" + ) + save_ext.parent.mkdir(parents=True, exist_ok=True) + save_ext.write_text( + """ +import json +from helpers import files +from helpers.extension import Extension + + +class ProjectExtraSaver(Extension): + def execute(self, data: dict = {}, **kwargs): + args = data.get("args") or () + call_kwargs = data.get("kwargs") or {} + project_name = args[0] if args else call_kwargs.get("name", "") + project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data") + if not isinstance(project_data, dict) or "extra" not in project_data: + return + forbidden = {"title", "mcp_servers", "git_token"} & set(project_data) + if forbidden: + raise AssertionError(f"core/transient keys leaked to extension save: {sorted(forbidden)}") + path = files.get_abs_path( + "usr", + "projects", + str(project_name or ""), + ".a0proj", + "extra_saved.json", + ) + files.write_file( + path, + json.dumps( + {"project": str(project_name or ""), "extra": project_data["extra"]}, + sort_keys=True, + ), + ) +""".lstrip(), + encoding="utf-8", + ) + _clear_runtime_caches() + + +def _add_project_conflict_plugin(tmp_path: Path): + plugin_dir = tmp_path / "plugins" / "_project_conflict" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + "name: _project_conflict\n", + encoding="utf-8", + ) + load_ext = ( + plugin_dir + / "extensions" + / "python" + / "_functions" + / "helpers" + / "projects" + / "load_project_extended_data" + / "end" + / "_30_project_conflict.py" + ) + load_ext.parent.mkdir(parents=True, exist_ok=True) + load_ext.write_text( + """ +from helpers.extension import Extension + + +class ProjectConflictLoader(Extension): + def execute(self, data: dict = {}, **kwargs): + result = data.get("result") + if not isinstance(result, dict): + result = {} + data["result"] = result + result["title"] = "Plugin-owned title" +""".lstrip(), + encoding="utf-8", + ) + _clear_runtime_caches() def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_path): @@ -247,6 +395,149 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch, assert "_model_config" not in project_json +def test_project_save_does_not_freeze_inherited_global_model_config( + monkeypatch, + tmp_path, +): + _prepare_a0_tree(monkeypatch, tmp_path) + + from helpers import plugins, projects + + projects.create_project("demo", {"title": "Demo"}) + config_path = ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "plugins" + / "_model_config" + / "config.json" + ) + + project_data = projects.load_edit_project_data("demo") + assert project_data["llm"]["config_scope"] == "inherited" + assert project_data["llm"]["has_project_config"] is False + + projects.update_project("demo", project_data) + + assert not config_path.exists() + + plugins.save_plugin_config( + "_model_config", + "", + "", + { + "chat_model": {"provider": "openrouter", "name": "new-global-chat"}, + "utility_model": {"provider": "openrouter", "name": "new-global-utility"}, + "embedding_model": { + "provider": "huggingface", + "name": "new-global-embedding", + }, + }, + ) + + reloaded_data = projects.load_edit_project_data("demo") + assert reloaded_data["llm"]["config_scope"] == "inherited" + assert reloaded_data["llm"]["config"]["chat_model"]["name"] == "new-global-chat" + + +def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path): + _prepare_a0_tree(monkeypatch, tmp_path) + + from helpers import plugins, projects + + projects.create_project("demo", {"title": "Demo"}) + plugins.save_plugin_config( + "_model_config", + "demo", + "", + { + "chat_model": {"provider": "openrouter", "name": "project-chat"}, + "utility_model": {"provider": "openrouter", "name": "project-utility"}, + "embedding_model": {"provider": "huggingface", "name": "project-embedding"}, + }, + ) + + project_data = projects.load_edit_project_data("demo") + assert project_data["llm"]["config_scope"] == "project" + project_data["llm"]["config"]["chat_model"]["name"] = "project-chat-updated" + + projects.update_project("demo", project_data) + + config_path = ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "plugins" + / "_model_config" + / "config.json" + ) + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["chat_model"]["name"] == "project-chat-updated" + + +def test_project_extended_data_supports_multiple_plugin_sections( + monkeypatch, + tmp_path, +): + _prepare_a0_tree(monkeypatch, tmp_path) + _add_project_extra_plugin(tmp_path) + + from helpers import projects + + projects.create_project( + "demo", + { + "title": "Demo", + "git_token": "secret-token", + "extra": {"enabled": False, "note": "created"}, + }, + ) + + saved_path = ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "extra_saved.json" + ) + assert json.loads(saved_path.read_text(encoding="utf-8")) == { + "project": "demo", + "extra": {"enabled": False, "note": "created"}, + } + + project_data = projects.load_edit_project_data("demo") + assert project_data["llm"]["config_scope"] == "inherited" + assert project_data["extra"] == {"loaded_for": "demo", "enabled": True} + + project_data["extra"] = {"enabled": True, "note": "updated"} + projects.update_project("demo", project_data) + + assert json.loads(saved_path.read_text(encoding="utf-8")) == { + "project": "demo", + "extra": {"enabled": True, "note": "updated"}, + } + + +def test_project_extended_data_cannot_overwrite_core_fields(monkeypatch, tmp_path): + _prepare_a0_tree(monkeypatch, tmp_path) + _add_project_conflict_plugin(tmp_path) + + from helpers import projects + + projects.create_project("demo", {"title": "Demo"}) + + with pytest.raises( + ValueError, + match="Project extension data cannot overwrite core project fields: title", + ): + projects.load_edit_project_data("demo") + + def test_preset_application_preserves_tuning_but_replaces_kwargs(monkeypatch, tmp_path): _prepare_a0_tree(monkeypatch, tmp_path)