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