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.

Alessandro committed Jun 23, 2026 at 07:34 UTC 51f3ed00a15838d0d6186f7cfefe7b3d9a0d5f96
7 files changed +439 -51
helpers/projects.py
+43 -48
@@ -1,7 +1,7 @@
1 import os
2 from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast
3
4 -from helpers import files, dirty_json, persist_chat, file_tree
4 +from helpers import files, dirty_json, persist_chat, file_tree, extension
5 from helpers.print_style import PrintStyle
6
7
@@ -62,6 +62,12 @@ class EditProjectData(BasicProjectData):
62 git_status: GitStatusData
63
64
65 +ProjectExtendedData = dict[str, object]
66 +_PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset(
67 + EditProjectData.__annotations__
68 +)
69 +_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"})
70 +
71
72 def get_projects_parent_folder():
73 return files.get_abs_path(PROJECTS_PARENT_DIR)
@@ -94,7 +100,7 @@ def delete_project(name: str):
100
101
102 def create_project(name: str, data: BasicProjectData):
97 - llm_data = data.get("llm") if isinstance(data, dict) else None
103 + extended_data = _project_extended_data_for_save(data)
104 mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
105 abs_path = files.create_dir_safe(
106 files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
@@ -103,7 +109,7 @@ def create_project(name: str, data: BasicProjectData):
109 data = _normalizeBasicData(data)
110 save_project_header(name, data)
111 save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG)
106 - save_project_llm_settings(name, llm_data)
112 + save_project_extended_data(name, extended_data)
113 return name
114
115
@@ -111,7 +117,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
117 """Clone a git repository as a new A0 project. Token is used only for cloning via http header."""
118 from helpers import git
119
114 - llm_data = data.get("llm") if isinstance(data, dict) else None
120 + extended_data = _project_extended_data_for_save(data)
121 mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
122
123 abs_path = files.create_dir_safe(
@@ -142,7 +148,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
148
149 if mcp_servers:
150 save_project_mcp_servers(actual_name, mcp_servers)
145 - save_project_llm_settings(actual_name, llm_data)
151 + save_project_extended_data(actual_name, extended_data)
152
153 return actual_name
154 except Exception as e:
@@ -242,7 +248,7 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData:
248
249
250 def update_project(name: str, data: EditProjectData):
245 - llm_data = data.get("llm") if isinstance(data, dict) else None
251 + extended_data = _project_extended_data_for_save(data)
252
253 # merge with current state
254 current = load_edit_project_data(name)
@@ -258,7 +264,7 @@ def update_project(name: str, data: EditProjectData):
264 save_project_secrets(name, current["secrets"])
265 save_project_mcp_servers(name, current["mcp_servers"])
266 save_project_subagents(name, current["subagents"])
261 - save_project_llm_settings(name, llm_data)
267 + save_project_extended_data(name, extended_data)
268
269 reactivate_project_in_chats(name)
270 return name
@@ -298,7 +304,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
304 },
305 )
306 data = _normalizeEditData(data)
301 - data["llm"] = load_project_llm_data(name) # type: ignore[typeddict-unknown-key]
307 + _merge_project_extended_data(data, load_project_extended_data(name))
308 return data
309
310
@@ -312,53 +318,42 @@ def save_project_header(name: str, data: BasicProjectData):
318 files.write_file(abs_path, header)
319
320
315 -def load_project_llm_data(name: str) -> dict:
316 - from plugins._model_config.helpers import model_config
321 +@extension.extensible
322 +def load_project_extended_data(name: str) -> ProjectExtendedData:
323 + return {}
324
318 - config = model_config.normalize_config_for_save(
319 - model_config.get_config(project_name=name) or {}
320 - )
325 +
326 +@extension.extensible
327 +def save_project_extended_data(name: str, project_data: ProjectExtendedData):
328 + return None
329 +
330 +
331 +def _project_extended_data_for_save(data: object) -> ProjectExtendedData:
332 + if not isinstance(data, dict):
333 + return {}
334 return {
322 - "config": config,
323 - "selected_preset": {
324 - "scope": "current",
325 - "project_name": name,
326 - "name": "Current config",
327 - },
328 - "presets": model_config.get_combined_presets(name),
329 - "global_presets": model_config.get_presets(),
330 - "project_presets": model_config.get_project_presets(name),
335 + str(key): value
336 + for key, value in data.items()
337 + if str(key) not in _PROJECT_CORE_EDIT_KEYS
338 + and str(key) not in _PROJECT_TRANSIENT_INPUT_KEYS
339 }
340
341
334 -def save_project_llm_settings(name: str, llm_data: object):
335 - if not isinstance(llm_data, dict):
342 +def _merge_project_extended_data(
343 + data: EditProjectData,
344 + extended_data: object,
345 +) -> None:
346 + if not isinstance(extended_data, dict):
347 return
348
338 - from helpers import plugins
339 - from plugins._model_config.helpers import model_config
340 -
341 - project_presets = llm_data.get("project_presets")
342 - if isinstance(project_presets, list):
343 - model_config.save_presets(project_presets, project_name=name)
344 -
345 - config_to_save = None
346 - selected_preset = llm_data.get("selected_preset")
347 - if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
348 - "global",
349 - "project",
350 - }:
351 - preset = model_config.resolve_preset_selection(selected_preset, project_name=name)
352 - if preset:
353 - base_config = model_config.get_config(project_name=name) or {}
354 - config_to_save = model_config.build_config_from_preset(preset, base_config)
355 -
356 - config = llm_data.get("config")
357 - if config_to_save is None and isinstance(config, dict):
358 - config_to_save = model_config.normalize_config_for_save(config)
359 -
360 - if config_to_save is not None:
361 - plugins.save_plugin_config("_model_config", name, "", config_to_save)
349 + conflicts = sorted(str(key) for key in extended_data if key in _PROJECT_CORE_EDIT_KEYS)
350 + if conflicts:
351 + raise ValueError(
352 + "Project extension data cannot overwrite core project fields: "
353 + + ", ".join(conflicts)
354 + )
355 +
356 + data.update(extended_data) # type: ignore[typeddict-item]
357
358
359 def load_project_mcp_servers(name: str) -> str:
helpers/projects.py.dox.md
+8 -3
@@ -34,8 +34,10 @@
34 - `load_basic_project_data(name: str) -> BasicProjectData`
35 - `load_edit_project_data(name: str) -> EditProjectData`
36 - `save_project_header(name: str, data: BasicProjectData)`
37 -- `load_project_llm_data(name: str) -> dict`
38 -- `save_project_llm_settings(name: str, llm_data: object)`
37 +- `load_project_extended_data(name: str) -> ProjectExtendedData`
38 +- `save_project_extended_data(name: str, project_data: ProjectExtendedData)`
39 +- `_project_extended_data_for_save(data: object) -> ProjectExtendedData`
40 +- `_merge_project_extended_data(data: EditProjectData, extended_data: object) -> None`
41 - `load_project_mcp_servers(name: str) -> str`
42 - `save_project_mcp_servers(name: str, mcp_servers: str)`
43 - `get_active_projects_list()`
@@ -57,6 +59,9 @@
59 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
60 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
61 - 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.
62 +- 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.
63 +- Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`.
64 +- 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.
65 - Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently.
66 - Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
67 - Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
@@ -65,7 +70,7 @@
70
71 ## Key Concepts
72
68 -- 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`.
73 +- 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`.
74 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
75
76 ## Work Guidance
plugins/_model_config/AGENTS.md
+1
@@ -14,6 +14,7 @@
14 ## Local Contracts
15
16 - Preserve global, project, agent, and chat override resolution order.
17 +- 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.
18 - Keep provider metadata and API-key checks safe around secrets.
19 - Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
20 - 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.
plugins/_model_config/extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py new
+14
@@ -0,0 +1,14 @@
1 +from helpers.extension import Extension
2 +from plugins._model_config.helpers import model_config
3 +
4 +
5 +class ProjectLlmDataProvider(Extension):
6 + def execute(self, data: dict = {}, **kwargs):
7 + result = data.get("result")
8 + if not isinstance(result, dict):
9 + result = {}
10 + data["result"] = result
11 +
12 + args = data.get("args") or ()
13 + project_name = args[0] if args else data.get("kwargs", {}).get("name", "")
14 + result["llm"] = model_config.load_project_llm_data(str(project_name or ""))
plugins/_model_config/extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py new
+12
@@ -0,0 +1,12 @@
1 +from helpers.extension import Extension
2 +from plugins._model_config.helpers import model_config
3 +
4 +
5 +class ProjectLlmSettingsSaver(Extension):
6 + def execute(self, data: dict = {}, **kwargs):
7 + args = data.get("args") or ()
8 + call_kwargs = data.get("kwargs") or {}
9 + project_name = args[0] if args else call_kwargs.get("name", "")
10 + project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data")
11 + llm_data = project_data.get("llm") if isinstance(project_data, dict) else None
12 + model_config.save_project_llm_settings(str(project_name or ""), llm_data)
plugins/_model_config/helpers/model_config.py
+70
@@ -99,6 +99,76 @@ def get_config(agent=None, project_name=None, agent_profile=None):
99 ) or {}
100
101
102 +def has_project_config(project_name: str) -> bool:
103 + path = plugins.determine_plugin_asset_path(
104 + "_model_config", project_name, "", plugins.CONFIG_FILE_NAME
105 + )
106 + return files.exists(path)
107 +
108 +
109 +def load_project_llm_data(project_name: str) -> dict:
110 + """Build the model-settings payload shown in Project Settings."""
111 + project_config_exists = has_project_config(project_name)
112 + config = normalize_config_for_save(get_config(project_name=project_name) or {})
113 + return {
114 + "config": config,
115 + "config_scope": "project" if project_config_exists else "inherited",
116 + "has_project_config": project_config_exists,
117 + "selected_preset": {
118 + "scope": "current",
119 + "project_name": project_name,
120 + "name": "Current config",
121 + },
122 + "presets": get_combined_presets(project_name),
123 + "global_presets": get_presets(),
124 + "project_presets": get_project_presets(project_name),
125 + }
126 +
127 +
128 +def save_project_llm_settings(project_name: str, llm_data: object) -> None:
129 + """Persist Project Settings model data without freezing inherited globals."""
130 + if not isinstance(llm_data, dict):
131 + return
132 +
133 + project_config_exists = has_project_config(project_name)
134 + project_presets = llm_data.get("project_presets")
135 + presets_path = _get_presets_path(project_name)
136 + if isinstance(project_presets, list) and (
137 + project_presets or files.exists(presets_path)
138 + ):
139 + save_presets(project_presets, project_name=project_name)
140 +
141 + config_to_save = None
142 + selected_preset = llm_data.get("selected_preset")
143 + if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
144 + PRESET_SCOPE_GLOBAL,
145 + PRESET_SCOPE_PROJECT,
146 + }:
147 + preset = resolve_preset_selection(selected_preset, project_name=project_name)
148 + if preset:
149 + base_config = get_config(project_name=project_name) or {}
150 + config_to_save = build_config_from_preset(preset, base_config)
151 +
152 + config = llm_data.get("config")
153 + config_scope = str(llm_data.get("config_scope") or "")
154 + config_is_inherited = config_scope == "inherited" and not project_config_exists
155 + should_save_config = (
156 + project_config_exists
157 + or config_scope == "project"
158 + or not config_scope
159 + )
160 + if (
161 + config_to_save is None
162 + and isinstance(config, dict)
163 + and should_save_config
164 + and not config_is_inherited
165 + ):
166 + config_to_save = normalize_config_for_save(config)
167 +
168 + if config_to_save is not None:
169 + plugins.save_plugin_config("_model_config", project_name, "", config_to_save)
170 +
171 +
172 def _load_presets_from_path(path: str) -> list | None:
173 if files.exists(path):
174 data = yaml_helper.loads(files.read_file(path))
tests/test_model_config_project_presets.py
+291
@@ -48,6 +48,21 @@ sys.modules.setdefault("watchdog.observers", watchdog.observers)
48 sys.modules.setdefault("watchdog.events", watchdog.events)
49
50
51 +def _copy_extension_fixture(plugin_dir: Path, relative_path: str) -> None:
52 + source = PROJECT_ROOT / "plugins" / "_model_config" / relative_path
53 + target = plugin_dir / relative_path
54 + target.parent.mkdir(parents=True, exist_ok=True)
55 + target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
56 +
57 +
58 +def _clear_runtime_caches():
59 + from helpers import cache, modules
60 +
61 + cache.clear("*(extensions)*")
62 + cache.clear("*(plugins)*")
63 + modules.purge_namespace("usr.plugins")
64 +
65 +
66 def _prepare_a0_tree(monkeypatch, tmp_path: Path):
67 from helpers import files, plugins
68
@@ -91,7 +106,140 @@ embedding_model:
106 """.lstrip(),
107 encoding="utf-8",
108 )
109 + _copy_extension_fixture(
110 + plugin_dir,
111 + "extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py",
112 + )
113 + _copy_extension_fixture(
114 + plugin_dir,
115 + "extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py",
116 + )
117 + (tmp_path / "usr" / "plugins").mkdir(parents=True)
118 (tmp_path / "usr" / "projects").mkdir(parents=True)
119 + _clear_runtime_caches()
120 +
121 +
122 +def _add_project_extra_plugin(tmp_path: Path):
123 + plugin_dir = tmp_path / "plugins" / "_project_extra"
124 + plugin_dir.mkdir(parents=True)
125 + (plugin_dir / "plugin.yaml").write_text(
126 + "name: _project_extra\n",
127 + encoding="utf-8",
128 + )
129 + load_ext = (
130 + plugin_dir
131 + / "extensions"
132 + / "python"
133 + / "_functions"
134 + / "helpers"
135 + / "projects"
136 + / "load_project_extended_data"
137 + / "end"
138 + / "_20_project_extra.py"
139 + )
140 + load_ext.parent.mkdir(parents=True, exist_ok=True)
141 + load_ext.write_text(
142 + """
143 +from helpers.extension import Extension
144 +
145 +
146 +class ProjectExtraLoader(Extension):
147 + def execute(self, data: dict = {}, **kwargs):
148 + result = data.get("result")
149 + if not isinstance(result, dict):
150 + result = {}
151 + data["result"] = result
152 + args = data.get("args") or ()
153 + project_name = args[0] if args else data.get("kwargs", {}).get("name", "")
154 + result["extra"] = {"loaded_for": str(project_name or ""), "enabled": True}
155 +""".lstrip(),
156 + encoding="utf-8",
157 + )
158 + save_ext = (
159 + plugin_dir
160 + / "extensions"
161 + / "python"
162 + / "_functions"
163 + / "helpers"
164 + / "projects"
165 + / "save_project_extended_data"
166 + / "start"
167 + / "_20_project_extra.py"
168 + )
169 + save_ext.parent.mkdir(parents=True, exist_ok=True)
170 + save_ext.write_text(
171 + """
172 +import json
173 +from helpers import files
174 +from helpers.extension import Extension
175 +
176 +
177 +class ProjectExtraSaver(Extension):
178 + def execute(self, data: dict = {}, **kwargs):
179 + args = data.get("args") or ()
180 + call_kwargs = data.get("kwargs") or {}
181 + project_name = args[0] if args else call_kwargs.get("name", "")
182 + project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data")
183 + if not isinstance(project_data, dict) or "extra" not in project_data:
184 + return
185 + forbidden = {"title", "mcp_servers", "git_token"} & set(project_data)
186 + if forbidden:
187 + raise AssertionError(f"core/transient keys leaked to extension save: {sorted(forbidden)}")
188 + path = files.get_abs_path(
189 + "usr",
190 + "projects",
191 + str(project_name or ""),
192 + ".a0proj",
193 + "extra_saved.json",
194 + )
195 + files.write_file(
196 + path,
197 + json.dumps(
198 + {"project": str(project_name or ""), "extra": project_data["extra"]},
199 + sort_keys=True,
200 + ),
201 + )
202 +""".lstrip(),
203 + encoding="utf-8",
204 + )
205 + _clear_runtime_caches()
206 +
207 +
208 +def _add_project_conflict_plugin(tmp_path: Path):
209 + plugin_dir = tmp_path / "plugins" / "_project_conflict"
210 + plugin_dir.mkdir(parents=True)
211 + (plugin_dir / "plugin.yaml").write_text(
212 + "name: _project_conflict\n",
213 + encoding="utf-8",
214 + )
215 + load_ext = (
216 + plugin_dir
217 + / "extensions"
218 + / "python"
219 + / "_functions"
220 + / "helpers"
221 + / "projects"
222 + / "load_project_extended_data"
223 + / "end"
224 + / "_30_project_conflict.py"
225 + )
226 + load_ext.parent.mkdir(parents=True, exist_ok=True)
227 + load_ext.write_text(
228 + """
229 +from helpers.extension import Extension
230 +
231 +
232 +class ProjectConflictLoader(Extension):
233 + def execute(self, data: dict = {}, **kwargs):
234 + result = data.get("result")
235 + if not isinstance(result, dict):
236 + result = {}
237 + data["result"] = result
238 + result["title"] = "Plugin-owned title"
239 +""".lstrip(),
240 + encoding="utf-8",
241 + )
242 + _clear_runtime_caches()
243
244
245 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,
395 assert "_model_config" not in project_json
396
397
398 +def test_project_save_does_not_freeze_inherited_global_model_config(
399 + monkeypatch,
400 + tmp_path,
401 +):
402 + _prepare_a0_tree(monkeypatch, tmp_path)
403 +
404 + from helpers import plugins, projects
405 +
406 + projects.create_project("demo", {"title": "Demo"})
407 + config_path = (
408 + tmp_path
409 + / "usr"
410 + / "projects"
411 + / "demo"
412 + / ".a0proj"
413 + / "plugins"
414 + / "_model_config"
415 + / "config.json"
416 + )
417 +
418 + project_data = projects.load_edit_project_data("demo")
419 + assert project_data["llm"]["config_scope"] == "inherited"
420 + assert project_data["llm"]["has_project_config"] is False
421 +
422 + projects.update_project("demo", project_data)
423 +
424 + assert not config_path.exists()
425 +
426 + plugins.save_plugin_config(
427 + "_model_config",
428 + "",
429 + "",
430 + {
431 + "chat_model": {"provider": "openrouter", "name": "new-global-chat"},
432 + "utility_model": {"provider": "openrouter", "name": "new-global-utility"},
433 + "embedding_model": {
434 + "provider": "huggingface",
435 + "name": "new-global-embedding",
436 + },
437 + },
438 + )
439 +
440 + reloaded_data = projects.load_edit_project_data("demo")
441 + assert reloaded_data["llm"]["config_scope"] == "inherited"
442 + assert reloaded_data["llm"]["config"]["chat_model"]["name"] == "new-global-chat"
443 +
444 +
445 +def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path):
446 + _prepare_a0_tree(monkeypatch, tmp_path)
447 +
448 + from helpers import plugins, projects
449 +
450 + projects.create_project("demo", {"title": "Demo"})
451 + plugins.save_plugin_config(
452 + "_model_config",
453 + "demo",
454 + "",
455 + {
456 + "chat_model": {"provider": "openrouter", "name": "project-chat"},
457 + "utility_model": {"provider": "openrouter", "name": "project-utility"},
458 + "embedding_model": {"provider": "huggingface", "name": "project-embedding"},
459 + },
460 + )
461 +
462 + project_data = projects.load_edit_project_data("demo")
463 + assert project_data["llm"]["config_scope"] == "project"
464 + project_data["llm"]["config"]["chat_model"]["name"] = "project-chat-updated"
465 +
466 + projects.update_project("demo", project_data)
467 +
468 + config_path = (
469 + tmp_path
470 + / "usr"
471 + / "projects"
472 + / "demo"
473 + / ".a0proj"
474 + / "plugins"
475 + / "_model_config"
476 + / "config.json"
477 + )
478 + config = json.loads(config_path.read_text(encoding="utf-8"))
479 + assert config["chat_model"]["name"] == "project-chat-updated"
480 +
481 +
482 +def test_project_extended_data_supports_multiple_plugin_sections(
483 + monkeypatch,
484 + tmp_path,
485 +):
486 + _prepare_a0_tree(monkeypatch, tmp_path)
487 + _add_project_extra_plugin(tmp_path)
488 +
489 + from helpers import projects
490 +
491 + projects.create_project(
492 + "demo",
493 + {
494 + "title": "Demo",
495 + "git_token": "secret-token",
496 + "extra": {"enabled": False, "note": "created"},
497 + },
498 + )
499 +
500 + saved_path = (
501 + tmp_path
502 + / "usr"
503 + / "projects"
504 + / "demo"
505 + / ".a0proj"
506 + / "extra_saved.json"
507 + )
508 + assert json.loads(saved_path.read_text(encoding="utf-8")) == {
509 + "project": "demo",
510 + "extra": {"enabled": False, "note": "created"},
511 + }
512 +
513 + project_data = projects.load_edit_project_data("demo")
514 + assert project_data["llm"]["config_scope"] == "inherited"
515 + assert project_data["extra"] == {"loaded_for": "demo", "enabled": True}
516 +
517 + project_data["extra"] = {"enabled": True, "note": "updated"}
518 + projects.update_project("demo", project_data)
519 +
520 + assert json.loads(saved_path.read_text(encoding="utf-8")) == {
521 + "project": "demo",
522 + "extra": {"enabled": True, "note": "updated"},
523 + }
524 +
525 +
526 +def test_project_extended_data_cannot_overwrite_core_fields(monkeypatch, tmp_path):
527 + _prepare_a0_tree(monkeypatch, tmp_path)
528 + _add_project_conflict_plugin(tmp_path)
529 +
530 + from helpers import projects
531 +
532 + projects.create_project("demo", {"title": "Demo"})
533 +
534 + with pytest.raises(
535 + ValueError,
536 + match="Project extension data cannot overwrite core project fields: title",
537 + ):
538 + projects.load_edit_project_data("demo")
539 +
540 +
541 def test_preset_application_preserves_tuning_but_replaces_kwargs(monkeypatch, tmp_path):
542 _prepare_a0_tree(monkeypatch, tmp_path)
543