mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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.
This commit is contained in:
parent
489acfe8c6
commit
51f3ed00a1
7 changed files with 438 additions and 50 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue