Finish scoped Agent Editor profile workflows

Add per-scope availability, duplication, active-profile status, and inline project selection while keeping the profile switcher synchronized with runtime state. Reconcile unavailable profiles at explicit disable, deletion, project-transition, and context-creation boundaries; polish validation, prompt layout, deletion, and completion states; and extend focused backend and WebUI coverage.

Alessandro committed Aug 10, 2026 at 01:42 UTC 9e5fd188e2820c2eca2e949de67422f6832d2c6c
28 files changed +1060 -158
agents/AGENTS.md
+2 -1
@@ -8,7 +8,8 @@
8 ## Ownership
9
10 - Each direct profile directory owns its `agent.yaml`, optional `prompts/`, optional `tools/`, and optional `extensions/`.
11 -- `_example/` demonstrates profile layout and should stay suitable as a reference.
11 +- `_example/` demonstrates profile layout, is not selectable, and should stay
12 + suitable as a reference.
13 - User-created local profiles belong under `usr/agents/`, not here, unless they are intended to ship with the product.
14
15 ## Local Contracts
agents/_example/AGENTS.md
+2 -1
@@ -7,7 +7,8 @@
7
8 ## Ownership
9
10 -- `agent.yaml` owns the example profile metadata.
10 +- This reference intentionally has no `agent.yaml`, so it is not discovered as
11 + a selectable profile.
12 - `prompts/` owns prompt override examples.
13 - `tools/` owns profile-local tool examples.
14 - `extensions/` owns profile-local lifecycle extension examples.
api/agent_profile_set.py
+7 -12
@@ -1,19 +1,11 @@
1 from agent import AgentContext
2 -from helpers import subagents
2 +from helpers import projects, subagents
3 from helpers.api import ApiHandler, Request, Response
4 from helpers.persist_chat import save_tmp_chat
5 from helpers.state_monitor_integration import mark_dirty_for_context
6 from initialize import initialize_agent
7
8
9 -def _agent_profile_labels() -> dict[str, str]:
10 - return {
11 - str(item.get("key") or ""): str(item.get("label") or item.get("key") or "")
12 - for item in subagents.get_all_agents_list()
13 - if item.get("key")
14 - }
15 -
16 -
9 class SetAgentProfile(ApiHandler):
10 async def process(self, input: dict, request: Request) -> dict | Response:
11 context_id = str(input.get("context_id", "") or "").strip()
@@ -33,8 +25,11 @@ class SetAgentProfile(ApiHandler):
25 response="Agent profile can be changed after the current run finishes.",
26 )
27
36 - labels = _agent_profile_labels()
37 - if profile not in labels:
28 + profiles = subagents.get_available_agents_dict(
29 + projects.get_context_project_name(context)
30 + )
31 + selected_profile = profiles.get(profile)
32 + if selected_profile is None:
33 return Response(status=404, response=f"Agent profile '{profile}' not found")
34
35 config = initialize_agent(override_settings={"agent_profile": profile})
@@ -46,5 +41,5 @@ class SetAgentProfile(ApiHandler):
41 return {
42 "ok": True,
43 "agent_profile": profile,
49 - "agent_profile_label": labels.get(profile, profile),
44 + "agent_profile_label": selected_profile.title or profile,
45 }
api/agent_profile_set.py.dox.md
+3 -3
@@ -13,8 +13,6 @@
13 - Classes:
14 - `SetAgentProfile` (`ApiHandler`)
15 - `async process(self, input: dict, request: Request) -> dict | Response`
16 -- Top-level functions:
17 -- `_agent_profile_labels() -> dict[str, str]`
16
17 ## Runtime Contracts
18
@@ -24,11 +22,13 @@
22 - `SetAgentProfile` defines `process(...)`.
23 - Observed side-effect areas: filesystem writes, settings/state persistence.
24 - Switching a chat profile updates the context and top-level agent profile only; existing subordinate agents keep their own profile configs.
25 +- A profile can be selected only when it is available in the chat's active
26 + project scope; profiles owned by other projects are not valid candidates.
27 - Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.persist_chat`, `helpers.state_monitor_integration`.
28
29 ## Key Concepts
30
31 -- Important called helpers/classes observed in the source: `str.strip`, `context.is_running`, `_agent_profile_labels`, `initialize_agent`, `context.agent0.config`, `save_tmp_chat`, `mark_dirty_for_context`, `subagents.get_all_agents_list`, `Response`.
31 +- Important called helpers/classes observed in the source: `str.strip`, `context.is_running`, `initialize_agent`, `context.agent0.config`, `save_tmp_chat`, `mark_dirty_for_context`, `projects.get_context_project_name`, `subagents.get_available_agents_dict`, `Response`.
32 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
33
34 ## Work Guidance
api/chat_create.py
+4
@@ -25,6 +25,10 @@ class CreateChat(ApiHandler):
25 if current_data_2:
26 new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
27
28 + projects.reconcile_agent_profile(
29 + new_context, projects.get_context_project_name(new_context)
30 + )
31 +
32 # copy model override from current context (only if override is allowed)
33 if current_context:
34 model_override = current_context.get_data("chat_model_override")
api/chat_create.py.dox.md
+3 -1
@@ -20,12 +20,14 @@
20 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
21 - `CreateChat` is an `ApiHandler`.
22 - `CreateChat` defines `process(...)`.
23 +- A newly created chat reconciles its profile after project inheritance, so it
24 + never keeps a profile unavailable in the inherited scope.
25 - Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence.
26 - Imported dependency areas include: `agent`, `helpers`, `helpers.api`.
27
28 ## Key Concepts
29
28 -- Important called helpers/classes observed in the source: `self.use_context`, `mark_dirty_all`, `guids.generate_id`, `current_context.get_data`, `current_context.get_output_data`, `new_context.set_data`, `new_context.set_output_data`, `is_chat_override_allowed`, `settings.get_settings`.
30 +- Important called helpers/classes observed in the source: `self.use_context`, `mark_dirty_all`, `guids.generate_id`, `current_context.get_data`, `current_context.get_output_data`, `new_context.set_data`, `new_context.set_output_data`, `is_chat_override_allowed`, `settings.get_settings`, `projects.get_context_project_name`, `projects.reconcile_agent_profile`.
31 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
32
33 ## Work Guidance
helpers/context_utils.py
+8 -2
@@ -8,6 +8,7 @@ ThreadLockType = Union[threading.Lock, threading.RLock]
8
9 def use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool = True):
10 from agent import AgentContext
11 + from helpers import projects
12 from initialize import initialize_agent
13
14 with lock:
@@ -17,6 +18,9 @@ def use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool = T
18 AgentContext.use(first.id)
19 return first
20 context = AgentContext(config=initialize_agent(), set_current=True)
21 + projects.reconcile_agent_profile(
22 + context, projects.get_context_project_name(context)
23 + )
24 return context
25 got = AgentContext.use(ctxid)
26 if got:
@@ -25,6 +29,8 @@ def use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool = T
29 context = AgentContext(
30 config=initialize_agent(), id=ctxid, set_current=True
31 )
32 + projects.reconcile_agent_profile(
33 + context, projects.get_context_project_name(context)
34 + )
35 return context
29 - else:
30 - raise Exception(f"Context {ctxid} not found")
36 + raise Exception(f"Context {ctxid} not found")
helpers/context_utils.py.dox.md
+6 -2
@@ -22,7 +22,10 @@
22
23 ## Key Concepts
24
25 -- Important called helpers/classes observed in the source: `AgentContext.use`, `AgentContext.first`, `AgentContext`, `Exception`, `initialize_agent`.
25 +- Newly constructed contexts reconcile their active profile against Global
26 + availability before returning. Existing context lookups return the stored
27 + context unchanged; explicit profile and project transitions own any repair.
28 +- Important called helpers/classes observed in the source: `AgentContext.use`, `AgentContext.first`, `AgentContext`, `Exception`, `initialize_agent`, `projects.get_context_project_name`, `projects.reconcile_agent_profile`.
29 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
30
31 ## Work Guidance
@@ -34,7 +37,8 @@
37 ## Verification
38
39 - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
37 -- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
40 +- `tests/test_projects.py` verifies that lookup is read-only while context
41 + creation reconciles once.
42
43 ## Child DOX Index
44
helpers/projects.py
+53 -5
@@ -405,6 +405,52 @@ def _get_projects_list(parent_dir):
405 return projects
406
407
408 +def reconcile_agent_profile(
409 + context: "AgentContext", project_name: str | None, available: dict | None = None
410 +) -> bool:
411 + from helpers import subagents
412 + from initialize import initialize_agent
413 +
414 + if available is None:
415 + available = subagents.get_available_agents_dict(project_name)
416 + if getattr(context.config, "profile", "") in available:
417 + return False
418 +
419 + config = initialize_agent()
420 + if config.profile not in available:
421 + fallback = "agent0" if "agent0" in available else next(iter(available), "agent0")
422 + config = initialize_agent(override_settings={"agent_profile": fallback})
423 + context.config = config
424 + context.agent0.config = config
425 + return True
426 +
427 +
428 +def reconcile_agent_profiles(
429 + project_name: str | None, *, all_scopes: bool = False
430 +) -> None:
431 + from agent import AgentContext
432 + from helpers import subagents
433 + from helpers.state_monitor_integration import mark_dirty_for_context
434 +
435 + available_by_project: dict[str | None, dict] = {}
436 + for context in AgentContext.all():
437 + context_project = get_context_project_name(context)
438 + if not all_scopes and context_project != project_name:
439 + continue
440 + if context_project not in available_by_project:
441 + available_by_project[context_project] = (
442 + subagents.get_available_agents_dict(context_project)
443 + )
444 + if not reconcile_agent_profile(
445 + context, context_project, available_by_project[context_project]
446 + ):
447 + continue
448 + persist_chat.save_tmp_chat(context)
449 + mark_dirty_for_context(
450 + context.id, reason="projects.reconcile_agent_profiles"
451 + )
452 +
453 +
454 def activate_project(context_id: str, name: str, *, mark_dirty: bool = True):
455 from agent import AgentContext
456
@@ -419,6 +465,7 @@ def activate_project(context_id: str, name: str, *, mark_dirty: bool = True):
465 CONTEXT_DATA_KEY_PROJECT,
466 {"name": name, "title": display_name, "color": data.get("color", "")},
467 )
468 + reconcile_agent_profile(context, name)
469
470 # persist
471 persist_chat.save_tmp_chat(context)
@@ -436,6 +483,7 @@ def deactivate_project(context_id: str, *, mark_dirty: bool = True):
483 raise Exception("Context not found")
484 context.set_data(CONTEXT_DATA_KEY_PROJECT, None)
485 context.set_output_data(CONTEXT_DATA_KEY_PROJECT, None)
486 + reconcile_agent_profile(context, None)
487
488 # persist
489 persist_chat.save_tmp_chat(context)
@@ -648,7 +696,7 @@ def load_project_subagents(name: str) -> dict[str, SubAgentSettings]:
696 abs_path = files.get_abs_path(get_project_meta(name), "agents.json")
697 data = dirty_json.parse(files.read_file(abs_path))
698 if isinstance(data, dict):
651 - return _normalize_subagents(data) # type: ignore[arg-type,return-value]
699 + return _normalize_subagents(data, name) # type: ignore[arg-type,return-value]
700 return {}
701 except Exception:
702 return {}
@@ -656,21 +704,21 @@ def load_project_subagents(name: str) -> dict[str, SubAgentSettings]:
704
705 def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings]):
706 abs_path = files.get_abs_path(get_project_meta(name), "agents.json")
659 - normalized = _normalize_subagents(subagents_data)
707 + normalized = _normalize_subagents(subagents_data, name)
708 content = dirty_json.stringify(normalized)
709 files.write_file(abs_path, content)
710
711
712 def _normalize_subagents(
665 - subagents_data: dict[str, SubAgentSettings]
713 + subagents_data: dict[str, SubAgentSettings], project_name: str = ""
714 ) -> dict[str, SubAgentSettings]:
715 from helpers import subagents
716
669 - agents_dict = subagents.get_agents_dict()
717 + scoped_agents = subagents.get_agents_dict(project_name or None)
718
719 normalized: dict[str, SubAgentSettings] = {}
720 for key, value in subagents_data.items():
673 - agent = agents_dict.get(key)
721 + agent = scoped_agents.get(key)
722 if not agent:
723 continue
724
helpers/projects.py.dox.md
+19 -1
@@ -42,6 +42,8 @@
42 - `save_project_mcp_servers(name: str, mcp_servers: str)`
43 - `get_active_projects_list()`
44 - `_get_projects_list(parent_dir)`
45 +- `reconcile_agent_profile(context: AgentContext, project_name: str | None) -> bool`
46 +- `reconcile_agent_profiles(project_name: str | None, *, all_scopes: bool=...) -> None`
47 - `activate_project(context_id: str, name: str, mark_dirty: bool=...)`
48 - `deactivate_project(context_id: str, mark_dirty: bool=...)`
49 - `reactivate_project_in_chats(name: str)`
@@ -54,6 +56,9 @@
56 - `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
57 - `_format_project_instruction_files(instruction_files: list[tuple[str, str]]) -> str`
58 - `_normalize_include_agents_md(value: object) -> bool`
59 +- `load_project_subagents(name: str) -> dict[str, SubAgentSettings]`
60 +- `save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings])`
61 +- `_normalize_subagents(subagents_data: dict[str, SubAgentSettings], project_name: str=...) -> dict[str, SubAgentSettings]`
62 - Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_SKILLS_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`.
63
64 ## Runtime Contracts
@@ -69,8 +74,21 @@
74 - Active-project AGENTS.md protocol guidance excludes the exact project root AGENTS.md because `build_system_prompt_vars(...)` already loads it into project instructions; prose for that protocol block lives in `prompts/agent.protocol.projects.agents_md.md`.
75 - Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
76 - Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
77 +- Activating or deactivating a project preserves the active chat profile when it
78 + is available in the destination scope; otherwise it replaces it with the
79 + configured default profile, then `agent0`, then the first available profile.
80 +- Per-project profile availability is persisted sparsely in `.a0proj/agents.json`;
81 + entries matching the profile definition's scoped default are omitted. The
82 + helper retains the established plain load/save contract for project settings.
83 +- Profile reconciliation treats `None` as the Global scope. Callers must pass
84 + `all_scopes=True` to check every loaded chat after a Global availability
85 + change. Each pass resolves the available profile catalog once per encountered
86 + scope; only chats whose active profile actually changes are persisted and
87 + marked dirty. Context creation uses the same reconciliation after resolving
88 + its scope, so a disabled configured profile cannot become invisibly active.
89 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
73 -- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`.
90 +- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`,
91 + `typing`.
92
93 ## Key Concepts
94
helpers/subagents.py
+5 -3
@@ -96,6 +96,8 @@ def _get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentLis
96 try:
97 raw = _read_agent_definition(dir, subdir)
98 except FileNotFoundError:
99 + if origin == "default":
100 + continue
101 raw = {}
102 agent_data = SubAgentListItem.model_validate(raw)
103 name = agent_data.name or subdir
@@ -312,9 +314,7 @@ def get_default_promp_file_names() -> list[str]:
314 def get_available_agents_dict(
315 project_name: str | None,
316 ) -> dict[str, SubAgentListItem]:
315 - # all available agents
316 - all_agents = get_agents_dict()
317 - # filter by project settings
317 + all_agents = get_agents_dict(project_name)
318 from helpers import projects
319
320 project_settings = (
@@ -323,6 +323,8 @@ def get_available_agents_dict(
323
324 filtered_agents: dict[str, SubAgentListItem] = {}
325 for name, agent in all_agents.items():
326 + if name == "_example":
327 + continue
328 if name in project_settings:
329 agent.enabled = project_settings[name]["enabled"]
330 if agent.enabled:
helpers/subagents.py.dox.md
+5
@@ -49,6 +49,11 @@
49 missing keys inherit while explicitly empty values remain overrides. Runtime
50 `name`, `path`, `origin`, and `prompts` fields are handled separately; derived
51 title fallbacks do not become authored overrides.
52 +- Available-profile resolution includes definitions from the selected project,
53 + then applies that project's sparse `agents.json` availability overrides.
54 +- Bundled directories require an authored profile definition; the `_example`
55 + reference directory is never selectable. Every real profile, including
56 + `Default`, follows the same Global and project availability rules.
57 - Important called helpers/classes observed in the source: `cache.toggle_area`, `model_validator`, `_get_agents_list_from_dir`, `plugins.get_enabled_plugin_paths`, `_merge_agent_dicts`, `files.get_subdirectories`, `_load_agent_data_from_dir`, `_merge_agent`, `files.write_file`, `files.delete_dir`, `SubAgent`, `SubAgentListItem`, `files.find_existing_paths_by_pattern`, `get_agents_roots`, `files.list_files`, `get_agents_dict`, `cache.determine_cache_key`, `cache.add`, `projects.get_project_meta`, `FileNotFoundError`.
58 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
59
plugins/_agent_editor/AGENTS.md
+17 -2
@@ -19,7 +19,13 @@
19 - Writes are limited to the selected profile layer — global
20 `usr/agents/<profile-id>` or project
21 `usr/projects/<project>/.a0proj/agents/<profile-id>` — and only to paths or
22 - config keys listed in the validated change plan.
22 + config keys listed in the validated change plan. The availability toggle
23 + reuses the existing project `.a0proj/agents.json` load/save owner; the WebUI
24 + permits one availability save at a time. Global availability remains a sparse
25 + `enabled` profile override.
26 +- Every profile, including `Default`, can be made unavailable. The backend
27 + rejects only the change that would leave the selected scope with no available
28 + profile, then reconciles loaded chats through the shared project owner.
29 - Never call `helpers.subagents.save_agent_data`.
30 - Authored profile definitions remain YAML; editor-written plugin configs remain
31 JSON.
@@ -41,7 +47,16 @@
47 - Model selection reuses `_model_config`'s compact preset dropdown and preset
48 editor; Agent Editor persists only the scoped preset reference.
49 - Manage agents reuses the plugin-settings project vocabulary: Global or one
44 - existing project. Save & test activates that same scope in the fresh chat.
50 + existing project. The active chat profile appears once above the list; each
51 + row exposes scoped availability, duplication, icon-only Edit, and Delete for
52 + profiles owned by that scope. Duplicate materializes the effective source
53 + profile into the selected writable layer with a collision-free ID and title.
54 + Availability changes quietly refresh the adjacent profile switcher catalog
55 + without a success toast.
56 +- The same project selector is available inside Create and Edit. Create keeps
57 + the in-progress draft when its destination changes; Edit reloads the selected
58 + profile from the new scope after guarding unsaved changes.
59 +- Save & test activates the saved profile in a fresh chat using that same scope.
60 - The WebUI uses the shared modal stack, labeled prompt scroll regions, and
61 24px-or-larger policy and text-action targets.
62
plugins/_agent_editor/README.md
+5
@@ -12,3 +12,8 @@ Global agents and customizations live under `usr/agents/<profile-id>` and apply
12 across projects. Project-scoped agents and customizations live under
13 `usr/projects/<project>/.a0proj/agents/<profile-id>`, inherit the Global layer,
14 and can be removed without changing it.
15 +
16 +Manage agents can duplicate the effective profile into the selected scope and
17 +toggle whether each profile is available there. Project availability reuses
18 +`.a0proj/agents.json`; Global availability is a sparse profile override.
19 +The Default profile always remains available.
plugins/_agent_editor/api/agent_editor.py
+60 -2
@@ -4,7 +4,7 @@ from pathlib import Path
4 from typing import Any
5
6 from agent import AgentContext
7 -from helpers import projects
7 +from helpers import projects, subagents
8 from helpers.api import ApiHandler, Request, Response
9 from plugins._agent_editor.helpers import editor
10
@@ -35,6 +35,43 @@ class AgentEditor(ApiHandler):
35 **receipt,
36 "effective_profile": editor.build_profile_state(profile_id, context),
37 }
38 + if action == "set_enabled":
39 + profile_id = editor.validate_profile_id(input.get("profile_id"))
40 + enabled = input.get("enabled")
41 + if not isinstance(enabled, bool):
42 + raise ValueError("Agent availability must be true or false.")
43 + if not enabled:
44 + project_name = editor._context_project_name(context)
45 + for live_context in AgentContext.all():
46 + if (
47 + getattr(live_context.config, "profile", "") == profile_id
48 + and live_context.is_running()
49 + and (
50 + not project_name
51 + or projects.get_context_project_name(live_context)
52 + == project_name
53 + )
54 + ):
55 + raise ValueError(
56 + "This agent is running. Disable it after the run finishes."
57 + )
58 + receipt = editor.set_profile_enabled(profile_id, enabled, context)
59 + return {
60 + "ok": True,
61 + **receipt,
62 + **_active_profile(input),
63 + }
64 + if action == "duplicate":
65 + profile_id = editor.validate_profile_id(input.get("profile_id"))
66 + plan, title = editor.plan_duplicate_profile(profile_id, context)
67 + receipt = editor.apply_change_plan(plan)
68 + return {
69 + "ok": True,
70 + **receipt,
71 + "profile_id": plan.profile_id,
72 + "title": title,
73 + "profiles": editor.list_profiles(context),
74 + }
75 if action in {"plan_remove_changes", "remove_changes"}:
76 profile_id = editor.validate_profile_id(input.get("profile_id"))
77 plan = editor.plan_remove_changes(
@@ -61,7 +98,12 @@ class AgentEditor(ApiHandler):
98 }
99 if input.get("confirm") is not True:
100 raise ValueError("Deleting a custom agent requires confirmation.")
64 - return {"ok": True, **editor.apply_change_plan(plan)}
101 + receipt = editor.apply_change_plan(plan)
102 + project_name = editor._context_project_name(context)
103 + projects.reconcile_agent_profiles(
104 + project_name or None, all_scopes=not project_name
105 + )
106 + return {"ok": True, **receipt}
107 raise ValueError(f"Unknown Agent Editor action: {action}")
108 except ValueError as exc:
109 return Response(status=400, response=str(exc), mimetype="text/plain")
@@ -80,3 +122,19 @@ def _context(input: dict[str, Any]) -> Any:
122 if not Path(projects.get_project_folder(project_name)).is_dir():
123 raise ValueError("Project not found.")
124 return editor._EditorContext(project_name)
125 +
126 +
127 +def _active_profile(input: dict[str, Any]) -> dict[str, str]:
128 + context_id = str(input.get("active_context_id") or "").strip()
129 + context = AgentContext.get(context_id) if context_id else None
130 + if not context:
131 + return {}
132 + profile_id = str(getattr(context.config, "profile", "") or "")
133 + profiles = subagents.get_available_agents_dict(
134 + projects.get_context_project_name(context)
135 + )
136 + profile = profiles.get(profile_id)
137 + return {
138 + "active_profile": profile_id,
139 + "active_profile_label": profile.title if profile else profile_id,
140 + }
plugins/_agent_editor/extensions/webui/chat-input-progress-start/agent-ready-note.html
+14 -1
@@ -5,7 +5,7 @@
5 <div x-data x-show="$store.agentEditor?.readyNoteVisible()" class="agent-ready-note" role="status" style="display:none">
6 <x-icon name="celebration" aria-hidden="true"></x-icon>
7 <span>Your agent is ready. You can refine it anytime from Edit agent.</span>
8 - <button type="button" class="button icon" aria-label="Dismiss agent ready note" @click="$store.agentEditor.dismissReadyNote()"><x-icon name="close"></x-icon></button>
8 + <button type="button" class="agent-ready-dismiss" aria-label="Dismiss agent ready note" @click="$store.agentEditor.dismissReadyNote()"><x-icon name="close"></x-icon></button>
9 </div>
10
11 <style>
@@ -23,4 +23,17 @@
23 font-size:.82rem;
24 }
25 .agent-ready-note > span { flex:1; }
26 + .agent-ready-dismiss {
27 + display:grid;
28 + flex:0 0 2rem;
29 + height:2rem;
30 + place-items:center;
31 + padding:0;
32 + border:0;
33 + background:transparent;
34 + color:inherit;
35 + cursor:pointer;
36 + opacity:.75;
37 + }
38 + .agent-ready-dismiss:hover { opacity:1; }
39 </style>
plugins/_agent_editor/helpers/editor.py
+130 -3
@@ -165,6 +165,7 @@ def profile_exists(profile_id: str, context: Any | None = None) -> bool:
165 def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
166 project_name = _context_project_name(context)
167 resolved = subagents.get_agents_dict(project_name)
168 + enabled = subagents.get_available_agents_dict(project_name)
169 names = set(resolved)
170 roots = [Path(files.get_abs_path("agents")), USER_AGENTS_ROOT]
171 if project_name:
@@ -190,7 +191,7 @@ def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
191 "deletable": state["deletable"],
192 "avatar": state["avatar"]["effective"],
193 "avatar_url": effective_avatar_url(profile_id, context),
193 - "enabled": bool(getattr(resolved.get(profile_id), "enabled", True)),
194 + "enabled": profile_id in enabled,
195 "available": profile_exists(profile_id, context),
196 }
197 )
@@ -468,7 +469,9 @@ def effective_avatar_url(profile_id: str, context: Any | None = None) -> str:
469 return "/api/plugins/_agent_editor/agent_editor_avatar?" + urlencode(query)
470
471
471 -def _metadata_layers(profile_id: str, context: Any | None) -> list[ProfileLayer]:
472 +def _profile_directories(
473 + profile_id: str, context: Any | None
474 +) -> list[tuple[str, Path]]:
475 agent = EditorAgent(profile_id, context)
476 directories: list[tuple[str, Path]] = [
477 (
@@ -488,10 +491,13 @@ def _metadata_layers(profile_id: str, context: Any | None) -> list[ProfileLayer]
491 Path(projects.get_project_meta(project_name, "agents", profile_id)),
492 )
493 )
494 + return directories
495 +
496
497 +def _metadata_layers(profile_id: str, context: Any | None) -> list[ProfileLayer]:
498 return [
499 layer
494 - for kind, directory in directories
500 + for kind, directory in _profile_directories(profile_id, context)
501 if (layer := _load_profile_layer(kind, directory)) is not None
502 ]
503
@@ -705,6 +711,127 @@ def build_change_plan(
711 return plan
712
713
714 +def plan_profile_enabled(
715 + profile_id: str,
716 + enabled: bool,
717 + context: Any | None = None,
718 +) -> ChangePlan:
719 + profile_id = validate_profile_id(profile_id)
720 + if _context_project_name(context):
721 + raise ValueError("Project availability is stored in project settings.")
722 + if not profile_exists(profile_id, context):
723 + raise ValueError(f'Agent profile "{profile_id}" does not exist.')
724 +
725 + root = _profile_root(profile_id)
726 + yaml_path = root / "agent.yaml"
727 + legacy_path = root / "agent.json"
728 + data = _read_mapping_strict(
729 + yaml_path if yaml_path.is_file() else legacy_path,
730 + "profile metadata",
731 + )
732 + lower_layers = [
733 + layer for layer in _metadata_layers(profile_id, context)
734 + if layer.kind != "user"
735 + ]
736 + inherited, _, _ = _layer_value(lower_layers, "enabled")
737 + inherited = True if inherited is None else bool(inherited)
738 + if enabled == inherited:
739 + data.pop("enabled", None)
740 + else:
741 + data["enabled"] = enabled
742 +
743 + plan = ChangePlan(profile_id=profile_id)
744 + if data:
745 + plan.write(yaml_path, yaml_helper.dumps(data))
746 + else:
747 + plan.delete(yaml_path)
748 + return plan
749 +
750 +
751 +def set_profile_enabled(
752 + profile_id: str,
753 + enabled: bool,
754 + context: Any | None = None,
755 +) -> dict[str, Any]:
756 + profile_id = validate_profile_id(profile_id)
757 + project_name = _context_project_name(context)
758 + if not profile_exists(profile_id, context):
759 + raise ValueError(f'Agent profile "{profile_id}" does not exist.')
760 +
761 + available = subagents.get_available_agents_dict(project_name or None)
762 + if not enabled and profile_id in available and len(available) == 1:
763 + raise ValueError("At least one agent profile must remain available.")
764 +
765 + if project_name:
766 + settings = projects.load_project_subagents(project_name)
767 + settings[profile_id] = {"enabled": enabled}
768 + projects.save_project_subagents(project_name, settings)
769 + receipt = {
770 + "written": [
771 + _relative_source(
772 + Path(projects.get_project_meta(project_name, "agents.json"))
773 + )
774 + ],
775 + "deleted": [],
776 + "warnings": [],
777 + }
778 + else:
779 + receipt = apply_change_plan(plan_profile_enabled(profile_id, enabled, context))
780 +
781 + if not enabled:
782 + projects.reconcile_agent_profiles(
783 + project_name or None, all_scopes=not project_name
784 + )
785 + return receipt
786 +
787 +
788 +def plan_duplicate_profile(
789 + profile_id: str,
790 + context: Any | None = None,
791 +) -> tuple[ChangePlan, str]:
792 + profile_id = validate_profile_id(profile_id)
793 + if not profile_exists(profile_id, context):
794 + raise ValueError(f'Agent profile "{profile_id}" does not exist.')
795 +
796 + state = metadata_state(profile_id, context)
797 + source_title = str(state["title"]["effective"] or profile_id).strip() or profile_id
798 + index = 1
799 + while True:
800 + suffix = f"-{index}"
801 + stem = profile_id[: 64 - len(suffix)].rstrip("-_")
802 + target_id = f"{stem}{suffix}"
803 + if not profile_exists(target_id, context):
804 + break
805 + index += 1
806 +
807 + target_title = f"{source_title} {index}"
808 + project_name = _context_project_name(context)
809 + target_root = _profile_root(target_id, project_name)
810 + plan = ChangePlan(profile_id=target_id, project_name=project_name)
811 +
812 + metadata: dict[str, Any] = {}
813 + for layer in _metadata_layers(profile_id, context):
814 + metadata.update(layer.data)
815 + for key in ("name", "path", "origin", "prompts", "enabled"):
816 + metadata.pop(key, None)
817 + metadata["title"] = target_title
818 + plan.write(target_root / "agent.yaml", yaml_helper.dumps(metadata))
819 +
820 + skipped = {"agent.yaml", "agent.json", "AGENTS.md"}
821 + for _, source_root in _profile_directories(profile_id, context):
822 + if not source_root.is_dir():
823 + continue
824 + if source_root.is_symlink():
825 + raise ValueError("Profile symlinks must be removed before duplication.")
826 + for source in sorted(source_root.rglob("*")):
827 + if source.is_symlink():
828 + raise ValueError("Profile symlinks must be removed before duplication.")
829 + if source.is_file() and source.name not in skipped:
830 + plan.write(target_root / source.relative_to(source_root), source.read_bytes())
831 +
832 + return plan, target_title
833 +
834 +
835 def _plan_metadata(
836 plan: ChangePlan,
837 value: Any,
plugins/_agent_editor/webui/agent-editor-store.js
+108 -24
@@ -87,6 +87,8 @@ const model = {
87 projects: [],
88 projectName: "",
89 profiles: [],
90 + profileAvailabilitySaving: false,
91 + duplicatingProfile: "",
92 draft: null,
93 initialDraft: null,
94 selectedPrompt: SPECIFICS,
@@ -146,6 +148,8 @@ const model = {
148 this.loading = true;
149 this.error = "";
150 this.pendingMutation = null;
151 + this.profileAvailabilitySaving = false;
152 + this.duplicatingProfile = "";
153 this.plan = { written: [], deleted: [], warnings: [] };
154 this.planStatus = "idle";
155 this.view = this.intent.view === "manage" ? "manage" : "editor";
@@ -221,12 +225,114 @@ const model = {
225 return this.projectName ? "Inherited" : "Default";
226 },
227
228 + activeProjectName() {
229 + const project = chatsStore.selectedContext?.project;
230 + return typeof project === "object"
231 + ? String(project?.name || "")
232 + : String(project || "");
233 + },
234 +
235 + currentChatUsesScope() {
236 + return Boolean(
237 + chatsStore.selected
238 + && (!this.projectName || this.projectName === this.activeProjectName())
239 + );
240 + },
241 +
242 + isProfileActive(profileId) {
243 + return this.currentChatUsesScope()
244 + && chatsStore.selectedContext?.agent_profile === profileId;
245 + },
246 +
247 + activeProfile() {
248 + return this.profiles.find((profile) => this.isProfileActive(profile.id)) || null;
249 + },
250 +
251 + async setProfileEnabled(profile, enabled) {
252 + if (!profile?.id || this.profileAvailabilitySaving) return;
253 + const previous = !!profile.enabled;
254 + profile.enabled = enabled;
255 + this.profileAvailabilitySaving = true;
256 + try {
257 + const data = await callJsonApi(API, {
258 + action: "set_enabled",
259 + profile_id: profile.id,
260 + enabled,
261 + active_context_id: chatsStore.selected || "",
262 + ...this.scopeInput(),
263 + });
264 + if (data.active_profile && chatsStore.selectedContext) {
265 + chatsStore.selectedContext.agent_profile = data.active_profile;
266 + chatsStore.selectedContext.agent_profile_label = data.active_profile_label || data.active_profile;
267 + }
268 + await modelConfigStore.loadAgentProfiles(true);
269 + } catch (error) {
270 + profile.enabled = previous;
271 + this.error = error.message || String(error);
272 + } finally {
273 + this.profileAvailabilitySaving = false;
274 + }
275 + },
276 +
277 + async duplicateProfile(profile) {
278 + if (!profile?.id || this.duplicatingProfile) return;
279 + this.duplicatingProfile = profile.id;
280 + this.error = "";
281 + try {
282 + const data = await callJsonApi(API, {
283 + action: "duplicate",
284 + profile_id: profile.id,
285 + ...this.scopeInput(),
286 + });
287 + this.profiles = data.profiles || [];
288 + await modelConfigStore.loadAgentProfiles(true);
289 + globalThis.justToast?.(
290 + `${data.title || profile.title || profile.id} created.`,
291 + "success", 1800, "agent-profile-duplicate",
292 + );
293 + } catch (error) {
294 + this.error = error.message || String(error);
295 + } finally {
296 + this.duplicatingProfile = "";
297 + }
298 + },
299 +
300 async onScopeChanged() {
225 - this.intent = { ...this.intent, projectName: this.projectName || "" };
301 + const previous = this.intent.projectName || "";
302 + const next = this.projectName || "";
303 + if (previous === next) return;
304 + if (this.view === "editor" && !this.draft?.creating && this.dirty
305 + && !window.confirm("Changing project will discard your unsaved changes. Continue?")) {
306 + this.projectName = previous;
307 + return;
308 + }
309 + this.intent = { ...this.intent, projectName: next };
310 this.loading = true;
311 this.error = "";
312 try {
313 await this.loadProfiles();
314 + if (this.view !== "editor" || !this.draft) return;
315 + if (!this.draft.creating
316 + && !this.profiles.some((profile) => profile.id === this.draft.profileId)) {
317 + this.projectName = previous;
318 + this.intent = { ...this.intent, projectName: previous };
319 + await this.loadProfiles();
320 + throw new Error("This agent is not available in the selected scope.");
321 + }
322 + const data = await callJsonApi(API, {
323 + action: "load",
324 + profile_id: this.draft.creating ? "new-agent" : this.draft.profileId,
325 + ...this.scopeInput(),
326 + });
327 + this.state = data.state;
328 + if (!this.draft.creating) this.makeDraft(false);
329 + } catch (error) {
330 + if (this.view === "editor" && this.projectName !== previous) {
331 + this.projectName = previous;
332 + this.intent = { ...this.intent, projectName: previous };
333 + await this.loadProfiles();
334 + }
335 + this.error = error.message || String(error);
336 } finally {
337 this.loading = false;
338 }
@@ -1079,14 +1185,9 @@ const model = {
1185
1186 async deleteProfile(profileId) {
1187 try {
1082 - const data = await callJsonApi(API, {
1083 - action: "plan_delete",
1084 - profile_id: profileId,
1085 - ...this.scopeInput(),
1086 - });
1188 const confirmed = await showConfirmDialog({
1189 title: `Delete ${escapeHtml(profileId)}?`,
1089 - message: `${this.deletionImpactHtml(data)}<p>This permanently removes this custom agent from ${escapeHtml(this.scopeLabel)}.</p>`,
1190 + message: `<p>This agent profile will be permanently deleted from ${escapeHtml(this.scopeLabel)} and cannot be recovered.</p>`,
1191 confirmText: "Delete agent",
1192 type: "danger",
1193 });
@@ -1107,23 +1208,6 @@ const model = {
1208 }
1209 },
1210
1110 - deletionImpactHtml(data) {
1111 - const impact = data?.impact || {};
1112 - const list = (values, empty) => values?.length
1113 - ? `<ul>${values.map((value) => `<li><code>${escapeHtml(value)}</code></li>`).join("")}</ul>`
1114 - : `<span>${empty}</span>`;
1115 - const contents = Object.entries(impact.contains || {})
1116 - .filter(([, present]) => present)
1117 - .map(([name]) => name);
1118 - return [
1119 - `<p><strong>Files</strong>${list(impact.files || data?.deleted || [], "None")}</p>`,
1120 - `<p><strong>Model preset</strong><br>${escapeHtml(impact.model_preset || "None")}</p>`,
1121 - `<p><strong>Projects using this agent</strong>${list(impact.project_references, "None found")}</p>`,
1122 - `<p><strong>Open chats using this agent</strong>${list(impact.active_sessions, "None found")}</p>`,
1123 - `<p><strong>Saved settings</strong><br>${escapeHtml(contents.length ? contents.join(", ") : "No additional settings or assets")}</p>`,
1124 - ].join("");
1125 - },
1126 -
1211 showManager() {
1212 if (this.dirty && !window.confirm("You have unsaved changes that will be lost. Continue?")) return;
1213 this.view = "manage";
plugins/_agent_editor/webui/main.html
+109 -72
@@ -27,27 +27,34 @@
27 <template x-if="!$store.agentEditor.loading && $store.agentEditor.view === 'manage'">
28 <section class="agent-manager" aria-label="Manage agents">
29 <div class="agent-scope-selector">
30 - <div class="agent-scope-header">
31 - <div class="agent-scope-copy">
32 - <strong>Agent scope</strong>
33 - <p>Choose whether agents and customizations apply globally or only to one project.</p>
30 + <label class="agent-scope-field">
31 + <span>Project</span>
32 + <select x-model="$store.agentEditor.projectName"
33 + x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"
34 + @change="$store.agentEditor.onScopeChanged()">
35 + <option value="">Global</option>
36 + <template x-for="project in $store.agentEditor.projects" :key="project.key">
37 + <option :value="project.key" x-text="project.label"></option>
38 + </template>
39 + </select>
40 + </label>
41 + </div>
42 + <div class="agent-manager-list-header">
43 + <template x-if="$store.agentEditor.activeProfile()">
44 + <div class="active-agent-display">
45 + <span class="active-agent-summary">
46 + <span>Active:</span>
47 + <span class="active-agent-avatar" aria-hidden="true" :style="`background:${$store.agentEditor.profileVisual($store.agentEditor.activeProfile()).color}`">
48 + <img x-show="$store.agentEditor.profileVisual($store.agentEditor.activeProfile()).url" :src="$store.agentEditor.profileVisual($store.agentEditor.activeProfile()).url" alt="">
49 + <span x-show="!$store.agentEditor.profileVisual($store.agentEditor.activeProfile()).url" x-text="$store.agentEditor.profileVisual($store.agentEditor.activeProfile()).initials"></span>
50 + </span>
51 + <strong x-text="$store.agentEditor.activeProfile().title || $store.agentEditor.activeProfile().id"></strong>
52 + </span>
53 + <button type="button" class="button icon-button" title="Edit" :aria-label="`Edit ${$store.agentEditor.activeProfile().title || $store.agentEditor.activeProfile().id}`" @click="$store.agentEditor.loadEditor($store.agentEditor.activeProfile().id, false)"><x-icon class="icon" name="edit"></x-icon></button>
54 </div>
35 - </div>
36 - <div class="agent-scope-toolbar">
37 - <label class="agent-scope-field">
38 - <span>Project</span>
39 - <select x-model="$store.agentEditor.projectName"
40 - x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"
41 - @change="$store.agentEditor.onScopeChanged()">
42 - <option value="">Global</option>
43 - <template x-for="project in $store.agentEditor.projects" :key="project.key">
44 - <option :value="project.key" x-text="project.label"></option>
45 - </template>
46 - </select>
47 - </label>
48 - </div>
55 + </template>
56 + <button type="button" class="button agent-manager-create" @click="$store.agentEditor.loadEditor('new-agent', true)"><x-icon class="icon" name="add"></x-icon>Create agent</button>
57 </div>
50 - <p class="agent-manager-intro" x-text="$store.agentEditor.projectName ? `Create agents and customize inherited profiles only for ${$store.agentEditor.scopeLabel}. Global and original files are never modified.` : 'Create agents and customize inherited profiles for every project. Originals are never modified.'"></p>
58 <div class="agent-manager-list">
59 <template x-for="profile in $store.agentEditor.profiles" :key="profile.id">
60 <article class="agent-manager-card">
@@ -59,16 +66,29 @@
66 <div class="agent-manager-name">
67 <strong x-text="profile.title || profile.id"></strong>
68 <span class="agent-origin" x-text="profile.origin"></span>
62 - <span class="agent-status-badge is-customized" x-show="profile.scope_has_overrides">
63 - <x-icon name="edit_note"></x-icon><span>Customized by you</span>
64 - </span>
69 + <span class="agent-customized-indicator" x-show="profile.scope_has_overrides" title="Customized" aria-label="Customized" role="img"><x-icon name="edit_note"></x-icon></span>
70 </div>
71 <p x-text="profile.description || 'No description'"></p>
67 - <code x-text="profile.id"></code>
72 + <button type="button" class="text-button agent-manager-inline-action"
73 + :disabled="!!$store.agentEditor.duplicatingProfile"
74 + :aria-label="`Duplicate ${profile.title || profile.id}`"
75 + @click="$store.agentEditor.duplicateProfile(profile)">
76 + <x-icon name="content_copy"></x-icon><span>Duplicate</span>
77 + </button>
78 </div>
79 <div class="agent-manager-actions">
70 - <button type="button" class="button" @click="$store.agentEditor.loadEditor(profile.id, false)"><x-icon name="edit"></x-icon>Edit</button>
71 - <button type="button" class="button danger" x-show="profile.deletable" @click="$store.agentEditor.deleteProfile(profile.id)"><x-icon name="delete"></x-icon>Delete</button>
80 + <label class="toggle agent-profile-availability"
81 + :title="`${profile.enabled ? 'Make unavailable' : 'Make available'} in ${$store.agentEditor.scopeLabel}`">
82 + <input type="checkbox"
83 + :checked="profile.enabled"
84 + :disabled="$store.agentEditor.profileAvailabilitySaving"
85 + :aria-label="`${profile.enabled ? 'Disable' : 'Enable'} ${profile.title || profile.id} in ${$store.agentEditor.scopeLabel}`"
86 + @change="$store.agentEditor.setProfileEnabled(profile, $event.target.checked)"
87 + @click.stop>
88 + <span class="toggler"></span>
89 + </label>
90 + <button type="button" class="button icon-button" title="Edit" :aria-label="`Edit ${profile.title || profile.id}`" @click="$store.agentEditor.loadEditor(profile.id, false)"><x-icon class="icon" name="edit"></x-icon></button>
91 + <button type="button" class="button cancel icon-button" x-show="profile.deletable" title="Delete" :aria-label="`Delete ${profile.title || profile.id}`" @click="$store.agentEditor.deleteProfile(profile.id)"><x-icon class="icon" name="delete"></x-icon></button>
92 </div>
93 </article>
94 </template>
@@ -81,7 +101,17 @@
101 <header class="agent-editor-topbar">
102 <div class="agent-editor-heading">
103 <button type="button" class="button icon" x-show="$store.agentEditor.intent.view === 'manage'" aria-label="Back to agents" @click="$store.agentEditor.showManager()"><x-icon name="arrow_back"></x-icon></button>
84 - <span class="agent-scope-indicator"><x-icon :name="$store.agentEditor.projectName ? 'folder' : 'public'"></x-icon><span>Scope:</span><strong x-text="$store.agentEditor.scopeLabel"></strong></span>
104 + <label class="agent-scope-field agent-editor-scope">
105 + <span>Project</span>
106 + <select x-model="$store.agentEditor.projectName"
107 + x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"
108 + @change="$store.agentEditor.onScopeChanged()">
109 + <option value="">Global</option>
110 + <template x-for="project in $store.agentEditor.projects" :key="project.key">
111 + <option :value="project.key" x-text="project.label"></option>
112 + </template>
113 + </select>
114 + </label>
115 <div class="agent-editor-subtitle" x-show="$store.agentEditor.dirty">
116 <span class="agent-status-badge is-unsaved"><x-icon name="edit_note"></x-icon><span>Unsaved changes</span></span>
117 </div>
@@ -106,13 +136,13 @@
136 <button type="button" class="text-button" x-show="$store.agentEditor.state.profile.metadata.avatar.has_override || $store.agentEditor.draft.avatar" @click="$store.agentEditor.resetAvatar()">Remove</button>
137 </div>
138 </div>
109 - <label class="agent-field agent-name-field">
110 - <span class="agent-field-label">Agent name</span>
139 + <div class="agent-field agent-name-field">
140 + <label for="agent-editor-name" class="agent-field-label">Agent name</label>
141 <input id="agent-editor-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')" required autocomplete="off" :aria-invalid="$store.agentEditor.fieldIssue('name') ? 'true' : null" :aria-describedby="$store.agentEditor.fieldIssue('name') ? 'agent-editor-name-error' : null">
112 - </label>
113 - <div class="agent-id-feedback" x-show="$store.agentEditor.fieldIssue('name')">
114 - <span id="agent-editor-name-error" class="field-error" role="alert" x-text="$store.agentEditor.fieldIssue('name')?.message"></span>
115 - <button type="button" class="text-button" x-show="$store.agentEditor.profileConflict" @click="$store.agentEditor.openConflictingProfile()">Open existing agent</button>
142 + <div class="agent-id-feedback" x-show="$store.agentEditor.fieldIssue('name')">
143 + <span id="agent-editor-name-error" class="field-error" role="alert" x-text="$store.agentEditor.fieldIssue('name')?.message"></span>
144 + <button type="button" class="text-button" x-show="$store.agentEditor.profileConflict" @click="$store.agentEditor.openConflictingProfile()">Open existing agent</button>
145 + </div>
146 </div>
147 </section>
148
@@ -143,7 +173,7 @@
173
174 <div class="agent-advanced" x-show="$store.agentEditor.mode === 'advanced'">
175 <nav class="agent-advanced-nav" aria-label="Advanced editor sections">
146 - <template x-for="item in [{id:'1',label:'Identity & models'},{id:'2',label:'Prompt files'},{id:'3',label:'Tools'},{id:'4',label:'Skills'},{id:'5',label:'Review'}]" :key="item.id">
176 + <template x-for="item in [{id:'1',label:'Identity'},{id:'2',label:'Prompt files'},{id:'3',label:'Tools'},{id:'4',label:'Skills'},{id:'5',label:'Review'}]" :key="item.id">
177 <button type="button" :class="{ active: $store.agentEditor.section === item.id }" :aria-current="$store.agentEditor.section === item.id ? 'step' : null" @click="$store.agentEditor.setSection(item.id)">
178 <span class="section-number" x-text="item.id"></span><span x-text="item.label"></span>
179 <span class="agent-status-badge compact" :class="$store.agentEditor.sectionIssues(item.id).length ? 'is-error' : 'is-unsaved'" x-show="$store.agentEditor.sectionIssues(item.id).length || $store.agentEditor.sectionDirty(item.id)"><x-icon :name="$store.agentEditor.sectionIssues(item.id).length ? 'error' : 'edit_note'"></x-icon><span x-text="$store.agentEditor.sectionIssues(item.id).length ? 'Needs attention' : 'Changed'"></span></span>
@@ -153,7 +183,7 @@
183
184 <div class="agent-advanced-content">
185 <section x-show="$store.agentEditor.section === '1'" data-agent-editor-section="1" tabindex="-1" aria-labelledby="agent-section-1-title">
156 - <header class="advanced-section-heading"><h3 id="agent-section-1-title">Identity & models</h3><p>Set the agent’s identity, delegation guidance, and model preset.</p></header>
186 + <header class="advanced-section-heading"><h3 id="agent-section-1-title">Identity</h3><p>Set the agent’s identity, delegation guidance, and model preset.</p></header>
187 <div class="origin-row">
188 <span class="agent-origin" x-text="$store.agentEditor.state.profile.origin"></span>
189 <span class="agent-status-badge is-customized" x-show="$store.agentEditor.state.profile.scope_has_overrides"><x-icon name="edit_note"></x-icon><span>Customized by you</span></span>
@@ -175,21 +205,22 @@
205 <div class="agent-field"><label for="agent-editor-advanced-name" class="agent-field-label">Agent name</label><input id="agent-editor-advanced-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')" :aria-invalid="$store.agentEditor.fieldIssue('name') ? 'true' : null" :aria-describedby="$store.agentEditor.fieldIssue('name') ? 'agent-editor-advanced-name-error' : null"><span id="agent-editor-advanced-name-error" class="field-error" role="alert" x-show="$store.agentEditor.fieldIssue('name')" x-text="$store.agentEditor.fieldIssue('name')?.message"></span><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('title')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('title')" @click="$store.agentEditor.resetMetadata('title')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('title')">Will reset to inherited on save.</small></div>
206 <label class="agent-field"><span class="agent-field-label">Profile ID</span><input type="text" :value="$store.agentEditor.draft.profileId" readonly aria-describedby="profile-id-help"><small id="profile-id-help">Used as the profile folder name. Existing IDs do not change when the display name changes.</small></label>
207 <div class="agent-field wide"><label for="agent-editor-description" class="agent-field-label">Description</label><textarea id="agent-editor-description" rows="2" x-model="$store.agentEditor.draft.description" @input="$store.agentEditor.markMetadataSet('description')"></textarea><small>Short summary shown in profile lists.</small><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('description')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('description')" @click="$store.agentEditor.resetMetadata('description')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('description')">Will reset to inherited on save.</small></div>
208 + <div class="agent-field wide agent-model-preset">
209 + <label for="agent-editor-model-preset" class="agent-field-label">Model preset</label>
210 + <div class="agent-model-preset-picker">
211 + <select id="agent-editor-model-preset" x-model="$store.agentEditor.draft.modelPreset">
212 + <option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
213 + <template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
214 + <option :value="preset.name" x-text="preset.name"></option>
215 + </template>
216 + </select>
217 + <button type="button" class="button" @click="$store.agentEditor.openPresetManager()"><x-icon class="icon" name="tune"></x-icon>Edit Presets</button>
218 + </div>
219 + <small>Use the current preset or choose another setup for this agent.</small>
220 + </div>
221 <div class="agent-field wide"><label for="agent-editor-context" class="agent-field-label">When should other agents use this agent?</label><textarea id="agent-editor-context" rows="3" x-model="$store.agentEditor.draft.context" @input="$store.agentEditor.markMetadataSet('context')"></textarea><small>Helps Agent Zero decide when to delegate work to this profile.</small><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('context')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('context')" @click="$store.agentEditor.resetMetadata('context')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('context')">Will reset to inherited on save.</small><span class="field-status" x-show="!$store.agentEditor.draft.context">Delegation quality can be lower while this is empty.</span></div>
222 </div>
223 </div>
181 - <div class="agent-model-preset">
182 - <label class="agent-model-preset-picker">
183 - <span><span class="agent-field-label">Model preset</span><small>Use the current preset or choose another setup for this agent.</small></span>
184 - <select x-model="$store.agentEditor.draft.modelPreset">
185 - <option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
186 - <template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
187 - <option :value="preset.name" x-text="preset.name"></option>
188 - </template>
189 - </select>
190 - </label>
191 - <button type="button" class="button" @click="$store.agentEditor.openPresetManager()"><x-icon class="icon" name="tune"></x-icon>Edit Presets</button>
192 - </div>
224 </section>
225
226 <section x-show="$store.agentEditor.section === '2'" data-agent-editor-section="2" tabindex="-1" aria-labelledby="agent-section-2-title">
@@ -301,7 +332,6 @@
332 <button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'editor'" @click="window.closeModal?.()">Cancel</button>
333 </div>
334 <div class="footer-actions">
304 - <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'manage'" @click="$store.agentEditor.loadEditor('new-agent', true)">Create agent</button>
335 <button type="button" class="btn agent-editor-secondary-action" x-show="$store.agentEditor.view === 'editor' && !$store.agentEditor.draft?.creating" @click="$store.agentEditor.save(true)" :disabled="$store.agentEditor.saving || $store.agentEditor.validationIssues().length > 0">Save & test</button>
336 <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'editor'" @click="$store.agentEditor.save(false)" :disabled="$store.agentEditor.saving || $store.agentEditor.avatarUploading || $store.agentEditor.validationIssues().length > 0" :title="$store.agentEditor.validationIssues().length ? 'Fix the highlighted issues before saving' : ''"><span x-text="$store.agentEditor.saving ? 'Saving…' : $store.agentEditor.draft?.creating ? 'Create agent' : 'Save changes'"></span></button>
337 </div>
@@ -334,10 +364,7 @@
364 .agent-editor-error span { flex:1; white-space:pre-wrap; }
365 .agent-editor-loading { min-height:20rem; display:grid; place-content:center; justify-items:center; gap:.65rem; color:var(--color-text-secondary); }
366 .agent-editor-topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
337 - .agent-editor-heading { display:flex; align-items:center; gap:.6rem; }
338 - .agent-scope-indicator { display:inline-flex; align-items:center; gap:.3rem; min-width:0; color:var(--color-text-secondary); font-size:.78rem; }
339 - .agent-scope-indicator strong { max-width:18rem; overflow:hidden; color:var(--color-text); text-overflow:ellipsis; white-space:nowrap; }
340 - .agent-scope-indicator x-icon { font-size:1rem; }
367 + .agent-editor-heading { display:flex; flex:1 1 auto; flex-wrap:wrap; align-items:center; gap:.6rem; min-width:0; }
368 .agent-editor-subtitle { margin-top:.2rem; font-size:.78rem; color:var(--color-text-secondary); }
369 .agent-status-badge { display:inline-flex; align-items:center; gap:.25rem; max-width:100%; padding:.2rem .45rem; border:1px solid var(--color-border); border-radius:999px; font-size:.7rem; font-weight:500; line-height:1.2; white-space:normal; }
370 .agent-status-badge x-icon { flex:0 0 auto; font-size:.85rem; }
@@ -362,10 +389,11 @@
389 .agent-name-field { align-self:start; }
390 .agent-field-label { font-weight:650; font-size:.92rem; }
391 .agent-field input,.agent-field textarea,.agent-easy textarea,.prompt-find input,.policy-filters input,.policy-filters select,.compact-search input { width:100%; box-sizing:border-box; }
365 - .agent-field small,.agent-field-heading p,.agent-id-feedback,.field-status { color:var(--color-text-secondary); font-size:.79rem; }
392 + .agent-field small,.agent-field-heading p,.field-status { color:var(--color-text-secondary); font-size:.79rem; }
393 .field-status { display:block; margin-top:.15rem; }
367 - .field-error { display:block; color:var(--color-text-secondary); font-size:.79rem; }
368 - .agent-id-feedback { grid-column:2; margin-top:-.6rem; }
394 + .field-error { display:block; color:var(--color-text); font-size:.79rem; font-weight:600; line-height:1.35; }
395 + .agent-id-feedback { display:flex; flex-wrap:wrap; align-items:center; gap:.35rem .65rem; }
396 + .agent-easy-field .field-error { margin-top:.35rem; }
397 .agent-field-heading { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; margin-bottom:.5rem; }
398 .agent-editor .text-button { display:inline-flex; align-items:center; min-height:1.5rem; border:0; padding:0; background:transparent; color:var(--agent-editor-action); cursor:pointer; font-size:.82rem; text-align:left; }
399 .agent-editor .text-button:hover { text-decoration:underline; }
@@ -392,12 +420,9 @@
420 .advanced-avatar { align-self:start; }
421 .identity-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.9rem; }
422 .identity-fields .agent-field.wide { grid-column:1/-1; }
395 - .agent-model-preset { display:flex; flex-direction:column; align-items:flex-start; gap:.75rem; }
396 - .agent-model-preset-picker { width:100%; display:grid; grid-template-columns:minmax(0,1fr) minmax(13rem,18rem); align-items:center; gap:1rem; margin:0; }
397 - .agent-model-preset-picker > span { display:flex; flex-direction:column; gap:.2rem; min-width:0; }
398 - .agent-model-preset-picker small { color:var(--color-text-secondary); font-size:.79rem; }
423 + .agent-model-preset-picker { width:100%; display:grid; grid-template-columns:minmax(13rem,18rem) auto; align-items:center; justify-content:space-between; gap:1rem; }
424 .agent-model-preset-picker select { width:100%; }
400 - .agent-model-preset > .button { display:inline-flex; align-items:center; gap:.35rem; }
425 + .agent-model-preset-picker > .button { display:inline-flex; align-items:center; gap:.35rem; }
426 .prompt-workspace { display:grid; grid-template-columns:minmax(13rem,16rem) minmax(0,1fr); gap:.8rem; min-width:0; height:34rem; }
427 .prompt-browser { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
428 .compact-search { display:flex; align-items:center; gap:.3rem; padding:.45rem; border-bottom:1px solid var(--color-border); }
@@ -424,9 +449,9 @@
449 .prompt-find span { color:var(--color-text-secondary); font-size:.75rem; white-space:nowrap; }
450 .prompt-panes { display:grid; grid-template-columns:1fr; gap:.65rem; min-height:18rem; }
451 .prompt-panes.compare { grid-template-columns:1fr 1fr; }
427 - .prompt-pane { min-width:0; display:flex; flex-direction:column; gap:.35rem; }
452 + .prompt-pane { min-width:0; min-height:0; display:flex; flex-direction:column; gap:.35rem; }
453 .prompt-pane label,.prompt-pane-title { color:var(--color-text-secondary); font-size:.75rem; }
429 - .prompt-pane textarea,.prompt-pane pre,.effective-preview pre { flex:1; box-sizing:border-box; width:100%; min-height:18rem; margin:0; padding:.75rem; overflow:auto; border:1px solid var(--color-border); border-radius:8px; background:var(--color-input); color:var(--color-text); font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; tab-size:2; resize:vertical; }
454 + .prompt-pane textarea,.prompt-pane pre,.effective-preview pre { flex:1; box-sizing:border-box; width:100%; min-height:0; margin:0; padding:.75rem; overflow:auto; border:1px solid var(--color-border); border-radius:8px; background:var(--color-input); color:var(--color-text); font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; tab-size:2; resize:vertical; }
455 .prompt-pane textarea:focus-visible { outline-offset:-2px; }
456 .prompt-pane.inherited pre { opacity:.86; }
457 .effective-preview { border:1px solid var(--color-border); border-radius:8px; padding:.55rem .7rem; }
@@ -482,24 +507,31 @@
507 .agent-editor .button.danger { display:inline-flex; align-items:center; gap:.35rem; border-color:color-mix(in srgb,var(--agent-editor-danger) 60%,var(--color-border)); color:var(--agent-editor-danger); background:color-mix(in srgb,var(--agent-editor-danger) 7%,var(--color-panel)); }
508 .agent-editor .button.danger:hover { background:color-mix(in srgb,var(--agent-editor-danger) 15%,var(--color-panel)); }
509 .agent-manager { display:flex; flex-direction:column; gap:1rem; }
485 - .agent-scope-selector { overflow:hidden; border:1px solid var(--color-border); border-radius:4px; }
486 - .agent-scope-header { padding:.75rem 1rem; border-bottom:1px solid var(--color-border); background:var(--color-bg-secondary); }
487 - .agent-scope-copy { min-width:0; }
488 - .agent-scope-copy strong { color:var(--color-text-primary); font-size:var(--font-size-normal); font-weight:600; }
489 - .agent-scope-copy p { margin-top:.25rem; color:var(--color-text-secondary); font-size:var(--font-size-small); }
490 - .agent-scope-toolbar { display:flex; padding:1rem; }
510 + .agent-scope-selector { padding:.75rem 1rem; border:1px solid var(--color-border); border-radius:4px; }
511 .agent-scope-field { display:flex; flex:1 1 0; align-items:center; gap:.5rem; min-width:12rem; margin:0; }
512 .agent-scope-field span { color:var(--color-text-secondary); font-weight:600; white-space:nowrap; }
513 .agent-scope-field select { flex:1 1 auto; min-width:0; }
494 - .agent-manager-intro,.agent-manager-copy p { color:var(--color-text-secondary); font-size:.82rem; }
514 + .agent-editor-scope { flex:0 1 24rem; }
515 + .agent-manager-copy p { color:var(--color-text-secondary); font-size:.82rem; }
516 + .agent-manager-list-header { display:flex; align-items:center; justify-content:flex-end; gap:1rem; }
517 + .active-agent-display { display:flex; align-items:center; gap:.5rem; min-width:0; margin-right:auto; padding:.5rem; border:1px solid var(--color-highlight); border-radius:.5rem; }
518 + .active-agent-summary { display:flex; align-items:center; gap:.5rem; min-width:0; }
519 + .active-agent-summary strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
520 + .active-agent-avatar { width:1.5rem; aspect-ratio:1; display:grid; flex:0 0 auto; place-items:center; overflow:hidden; border-radius:6px; color:#fff; font-size:.65rem; font-weight:700; }
521 + .active-agent-avatar img { width:100%; height:100%; object-fit:cover; }
522 + .agent-manager-create { flex:0 0 auto; display:inline-flex; align-items:center; gap:.35rem; }
523 .agent-manager-list { display:flex; flex-direction:column; gap:.55rem; }
524 .agent-manager-card { display:grid; grid-template-columns:3rem minmax(0,1fr) auto; gap:.75rem; align-items:center; padding:.75rem; border:1px solid var(--color-border); border-radius:10px; }
525 .agent-manager-avatar { width:3rem; aspect-ratio:1; display:grid; place-items:center; border-radius:10px; color:white; font-weight:700; overflow:hidden; }
526 .agent-manager-avatar img { width:100%; height:100%; object-fit:cover; }
527 .agent-manager-copy { min-width:0; }
528 .agent-manager-name { display:flex; flex-wrap:wrap; align-items:center; gap:.4rem; }
501 - .agent-manager-copy code { font-size:.7rem; color:var(--color-text-secondary); }
502 - .agent-manager-actions { display:flex; flex-wrap:wrap; gap:.4rem; }
529 + .agent-customized-indicator { display:inline-grid; place-items:center; color:var(--color-text-secondary); }
530 + .agent-customized-indicator x-icon { font-size:1rem; }
531 + .agent-editor .agent-manager-inline-action { gap:.25rem; margin-top:.15rem; color:var(--color-message-text); opacity:.7; }
532 + .agent-editor .agent-manager-inline-action:hover:not(:disabled) { background:transparent; color:var(--color-text); text-decoration:none; opacity:1; }
533 + .agent-manager-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.4rem; }
534 + .agent-profile-availability input { width:0; height:0; margin:0; border:0; }
535 .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
536 .toast-link { border:0; background:transparent; color:var(--agent-editor-action); text-decoration:underline; cursor:pointer; }
537 .agent-editor :focus-visible { outline:2px solid var(--agent-editor-action); outline-offset:2px; }
@@ -509,10 +541,12 @@
541 .modal-inner.agent-editor-advanced .agent-editor,.modal-inner.agent-editor-advanced .agent-editor-workspace { min-height:100%; }
542 @media (max-width: 760px) {
543 .modal-inner.agent-editor-easy,.modal-inner.agent-editor-advanced { width:100vw; max-width:none; height:100vh; max-height:none; border-radius:0; }
512 - .agent-editor-topbar { align-items:flex-start; }
544 + .agent-editor-topbar { align-items:flex-start; flex-wrap:wrap; }
545 + .agent-editor-heading { width:100%; }
546 + .agent-editor-scope { flex:1 1 14rem; }
547 .agent-easy-identity { grid-template-columns:1fr; justify-items:center; padding-top:1.8rem; }
548 .agent-name-field { width:100%; }
515 - .agent-id-feedback { grid-column:1; width:100%; margin:0; }
549 + .agent-id-feedback { width:100%; }
550 .agent-advanced { grid-template-columns:1fr; }
551 .agent-advanced-nav { position:static; flex-direction:row; overflow-x:auto; }
552 .agent-advanced-nav button { grid-template-columns:auto auto auto; white-space:nowrap; }
@@ -526,6 +560,9 @@
560 .identity-fields .agent-field.wide { grid-column:1; }
561 .policy-filters { grid-template-columns:1fr; }
562 .policy-list { min-height:20rem; }
563 + .agent-manager-list-header { flex-wrap:wrap; }
564 + .active-agent-display { width:100%; }
565 + .agent-manager-create { margin-left:auto; }
566 .agent-manager-card { grid-template-columns:3rem minmax(0,1fr); }
567 .agent-manager-actions { grid-column:1/-1; justify-content:flex-end; }
568 .agent-scope-field { flex-basis:100%; min-width:0; }
plugins/_model_config/AGENTS.md
+3 -1
@@ -28,7 +28,9 @@
28 - `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
29 - The preset editor maps each model provider's API-key field to the shared API-key store; saving the editor persists dirty keys separately and never writes secrets into preset YAML.
30 - The compact chat selector label combines the effective preset with only the leaf name of its main model; utility and provider text stay out of the closed selector.
31 -- The adjacent agent-profile selector reads the always-enabled Agent Editor list endpoint directly so the active profile shows its effective title and avatar.
31 +- The adjacent agent-profile selector reads the always-enabled Agent Editor list endpoint directly so the active profile shows its effective title and avatar, and omits profiles disabled in the chat's current scope.
32 +- Reload the agent-profile selector catalog when a chat changes project or
33 + active profile so project-only profiles never linger in the visible choices.
34 - Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults.
35 - Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`.
36 - Migration must preserve existing definitions and distinct scoped model choices, back up replaced user files once, strip inline secrets, and remain idempotent.
plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js
+2
@@ -26,6 +26,7 @@ export default async function refreshSwitcherOnOverrideRevision(ctx) {
26 String(projectName),
27 String(activeContext?.agent_profile || ""),
28 ]);
29 + const scopeChanged = contextId !== lastContextId || scopeKey !== lastScopeKey;
30
31 if (
32 contextId === lastContextId
@@ -36,5 +37,6 @@ export default async function refreshSwitcherOnOverrideRevision(ctx) {
37 lastContextId = contextId;
38 lastRevision = revision;
39 lastScopeKey = scopeKey;
40 + if (scopeChanged) await modelConfigStore.loadAgentProfiles(true);
41 await modelConfigStore.refreshSwitcher(contextId);
42 }
plugins/_model_config/webui/switcher-mixin.js
+1 -1
@@ -48,7 +48,7 @@ export const switcherMethods = {
48 context_id: contextId,
49 });
50 this.agentProfiles = (data.profiles || [])
51 - .filter(profile => profile.id && profile.id !== "_example")
51 + .filter(profile => profile.id && profile.id !== "_example" && profile.enabled !== false)
52 .map(profile => ({
53 key: profile.id,
54 label: profile.title || profile.id,
tests/test_agent_editor.py
+129
@@ -559,6 +559,135 @@ def test_display_title_change_keeps_profile_id_and_builtin_delete_is_rejected(
559 assert not (user_root / "renamed-display").exists()
560
561
562 +def test_profile_availability_is_a_sparse_global_override(user_root: Path) -> None:
563 + disabled = editor.plan_profile_enabled("researcher", False)
564 +
565 + assert list(disabled.changes) == [user_root / "researcher" / "agent.yaml"]
566 + editor.apply_change_plan(disabled)
567 + assert yaml_helper.loads(
568 + (user_root / "researcher" / "agent.yaml").read_text(encoding="utf-8")
569 + ) == {"enabled": False}
570 +
571 + restored = editor.plan_profile_enabled("researcher", True)
572 + editor.apply_change_plan(restored)
573 + assert not (user_root / "researcher" / "agent.yaml").exists()
574 +
575 +
576 +def test_default_profile_can_be_disabled_when_another_profile_is_available(
577 + user_root: Path,
578 + project_scope: tuple[editor._EditorContext, Path],
579 +) -> None:
580 + context, _ = project_scope
581 +
582 + editor.set_profile_enabled("default", False)
583 + assert yaml_helper.loads(
584 + (user_root / "default" / "agent.yaml").read_text(encoding="utf-8")
585 + ) == {"enabled": False}
586 + editor.set_profile_enabled("default", True)
587 + assert not (user_root / "default" / "agent.yaml").exists()
588 +
589 + editor.set_profile_enabled("default", False, context)
590 + assert editor.projects.load_project_subagents("demo") == {
591 + "default": {"enabled": False}
592 + }
593 +
594 +
595 +def test_last_available_profile_cannot_be_disabled(
596 + user_root: Path, monkeypatch: pytest.MonkeyPatch
597 +) -> None:
598 + monkeypatch.setattr(
599 + editor.subagents,
600 + "get_available_agents_dict",
601 + lambda _project=None: {
602 + "default": editor.subagents.SubAgentListItem(name="default")
603 + },
604 + )
605 +
606 + with pytest.raises(ValueError, match="At least one agent profile"):
607 + editor.set_profile_enabled("default", False)
608 +
609 + assert not (user_root / "default").exists()
610 +
611 +
612 +def test_duplicate_profile_materializes_the_effective_profile(
613 + user_root: Path,
614 +) -> None:
615 + plan, title = editor.plan_duplicate_profile("developer")
616 +
617 + assert plan.profile_id == "developer-1"
618 + assert title == "Developer 1"
619 + assert all(path.is_relative_to(user_root / "developer-1") for path in plan.changes)
620 + editor.apply_change_plan(plan)
621 +
622 + duplicate = user_root / "developer-1"
623 + metadata = yaml_helper.loads(
624 + (duplicate / "agent.yaml").read_text(encoding="utf-8")
625 + )
626 + assert metadata["title"] == "Developer 1"
627 + assert metadata["description"] == "Agent specialized in complex software development."
628 + assert "enabled" not in metadata
629 + assert (duplicate / "prompts" / editor.SPECIFICS_FILE).read_bytes() == (
630 + Path("agents/developer/prompts") / editor.SPECIFICS_FILE
631 + ).read_bytes()
632 + assert not (duplicate / "AGENTS.md").exists()
633 +
634 + next_plan, next_title = editor.plan_duplicate_profile("developer")
635 + assert next_plan.profile_id == "developer-2"
636 + assert next_title == "Developer 2"
637 +
638 +
639 +def test_duplicate_profile_targets_the_selected_project(
640 + project_scope: tuple[editor._EditorContext, Path],
641 +) -> None:
642 + context, project_agents = project_scope
643 +
644 + plan, title = editor.plan_duplicate_profile("developer", context)
645 +
646 + assert plan.project_name == "demo"
647 + assert plan.profile_id == "developer-1"
648 + assert title == "Developer 1"
649 + assert all(
650 + path.is_relative_to(project_agents / "developer-1")
651 + for path in plan.changes
652 + )
653 +
654 +
655 +def test_project_profile_availability_uses_project_settings(
656 + project_scope: tuple[editor._EditorContext, Path],
657 + monkeypatch: pytest.MonkeyPatch,
658 +) -> None:
659 + context, _ = project_scope
660 + reconciled: list[tuple[tuple, dict]] = []
661 + monkeypatch.setattr(
662 + editor.projects,
663 + "reconcile_agent_profiles",
664 + lambda *args, **kwargs: reconciled.append((args, kwargs)),
665 + )
666 + monkeypatch.setattr(
667 + editor.subagents,
668 + "get_agents_dict",
669 + lambda _project=None: {
670 + "default": editor.subagents.SubAgentListItem(
671 + name="default", enabled=True
672 + ),
673 + "researcher": editor.subagents.SubAgentListItem(
674 + name="researcher", enabled=True
675 + )
676 + },
677 + )
678 +
679 + editor.set_profile_enabled("researcher", False, context)
680 +
681 + assert editor.projects.load_project_subagents("demo") == {
682 + "researcher": {"enabled": False}
683 + }
684 +
685 + editor.set_profile_enabled("researcher", True, context)
686 +
687 + assert editor.projects.load_project_subagents("demo") == {}
688 + assert reconciled == [(("demo",), {"all_scopes": False})]
689 +
690 +
691 def test_save_rolls_back_every_file_after_commit_failure(
692 user_root: Path,
693 monkeypatch: pytest.MonkeyPatch,
tests/test_agent_editor_webui.py
+91 -14
@@ -26,6 +26,7 @@ SWITCHER_MIXIN = ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.
26
27 def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls() -> None:
28 modal = MODAL.read_text(encoding="utf-8")
29 + store = STORE.read_text(encoding="utf-8")
30 switcher = SWITCHER.read_text(encoding="utf-8")
31 tool_section = re.search(
32 r'data-agent-editor-section="3".*?(?=<section x-show="\$store\.agentEditor\.section === \'4\'")',
@@ -55,7 +56,7 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
56 assert all(
57 label in modal
58 for label in (
58 - "Identity & models",
59 + "Identity",
60 "Prompt files",
61 "Tools",
62 "Skills",
@@ -100,9 +101,31 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
101 assert 'x-for="project in $store.agentEditor.projects"' in modal
102 assert 'x-show="profile.deletable"' in modal
103 assert 'x-show="profile.scope_has_overrides"' in modal
104 + assert 'class="agent-customized-indicator"' in modal
105 + assert 'title="Customized"' in modal
106 + assert 'class="button agent-manager-create"' in modal
107 + assert 'class="active-agent-display"' in modal
108 + assert 'class="button icon-button" title="Edit"' in modal
109 + assert 'class="text-button agent-manager-inline-action"' in modal
110 + inline_action_style = re.search(
111 + r"\.agent-editor \.agent-manager-inline-action\s*\{([^}]*)\}", modal
112 + ).group(1)
113 + assert "color:var(--color-message-text)" in inline_action_style
114 + assert ".agent-editor .agent-manager-inline-action:hover:not(:disabled)" in modal
115 + assert ':disabled="!!$store.agentEditor.duplicatingProfile"' in modal
116 + assert ':aria-label="`Duplicate ${profile.title || profile.id}`"' in modal
117 + assert "$store.agentEditor.duplicateProfile(profile)" in modal
118 + assert 'class="toggle agent-profile-availability"' in modal
119 + assert ':disabled="$store.agentEditor.profileAvailabilitySaving"' in modal
120 + assert "Default is always available" not in modal
121 + assert "$store.agentEditor.setProfileEnabled(profile, $event.target.checked)" in modal
122 + assert "agent-profile-availability" not in store
123 + assert "activateProfile(profile.id)" not in modal
124 + assert 'class="button cancel icon-button" x-show="profile.deletable"' in modal
125 assert "project_override_active" not in modal
126 assert "profile.origin === 'Custom'" not in modal
105 - assert "Scope:" in modal
127 + assert "Agent scope" not in modal
128 + assert "Create agents and customize inherited profiles" not in modal
129 assert "Unavailable — kept in your settings" in modal
130 assert "Customize this file" not in modal
131 assert 'role="tablist" aria-label="Prompt view"' in modal
@@ -143,15 +166,9 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
166 assert 'aria-label="Accept current edit"' in modal
167 assert ':readonly="!$store.agentEditor.isPromptEditing' not in modal
168 assert ".prompt-pane textarea:focus-visible { outline-offset:-2px; }" in modal
146 - assert all(
147 - label in STORE.read_text(encoding="utf-8")
148 - for label in (
149 - "Model preset",
150 - "Projects using this agent",
151 - "Open chats using this agent",
152 - "Saved settings",
153 - )
154 - )
169 + store_source = STORE.read_text(encoding="utf-8")
170 + assert "cannot be recovered" in store_source
171 + assert "deletionImpactHtml" not in store_source
172 assert "agent-profile-avatar" in switcher
173 assert '<button type="button" class="model-switcher-item agent-profile-item"' in switcher
174 assert '<div class="model-switcher-item agent-profile-item"' not in switcher
@@ -163,15 +180,16 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
180 assert ".easy-tool-summary" not in modal
181 assert "grid-template-columns:minmax(0,1fr) 2.5rem minmax(0,1fr)" in modal
182 assert ".policy-lists .policy-transfer-actions { flex-direction:row; }" in modal
166 - store_source = STORE.read_text(encoding="utf-8")
183 assert "easyToolsOpen" not in store_source
184 assert "easySkills" not in store_source
185 assert "get toolMode" not in store_source
186 assert "get easyTools" not in store_source
187 assert "firstSentence" not in store_source
188 assert '.agent-editor [aria-invalid="true"]' not in modal
173 - assert ".field-error { display:block; color:var(--color-text-secondary)" in modal
189 + assert ".field-error { display:block; color:var(--color-text)" in modal
190 + assert ".prompt-pane { min-width:0; min-height:0;" in modal
191 assert 'callJsonApi("/plugins/_agent_editor/agent_editor"' in switcher_mixin
192 + assert "profile.enabled !== false" in switcher_mixin
193 assert "@keydown.ctrl.s.prevent" in modal
194 assert "@media (max-width: 760px)" in modal
195 assert modal.count("data-modal-footer") == 1
@@ -199,9 +217,19 @@ def test_local_slugging_and_fresh_chat_profile_selection_are_deterministic() ->
217 harness = r"""
218 const calls = [];
219 const confirmations = [];
220 +let setEnabledHandler = null;
221 const createStore = (_name, value) => value;
222 const callJsonApi = async (endpoint, payload) => {
223 calls.push({ endpoint, payload });
224 + if (payload?.action === "set_enabled") return setEnabledHandler
225 + ? setEnabledHandler(payload)
226 + : { ok: true, active_profile: "default", active_profile_label: "Default" };
227 + if (payload?.action === "duplicate") return {
228 + ok: true,
229 + profile_id: "researcher-1",
230 + title: "Researcher 1",
231 + profiles: [{ id: "researcher" }, { id: "researcher-1" }],
232 + };
233 return endpoint === "/chat_create" ? { ok: true, ctxid: "fresh-chat" } : { ok: true };
234 };
235 const fetchApi = async () => ({ ok: true, json: async () => ({}) });
@@ -210,10 +238,15 @@ const openModal = async () => {};
238 const showConfirmDialog = async options => { confirmations.push(options); return false; };
239 const chatsStore = {
240 selected: "old-chat",
241 + selectedContext: { project: { name: "demo" }, agent_profile: "researcher" },
242 selectChat: async (id) => calls.push({ endpoint: "selectChat", payload: id }),
243 };
244 const modelConfigStore = {
216 - loadAgentProfiles: async () => {},
245 + loadAgentProfiles: async force => calls.push({ endpoint: "loadAgentProfiles", payload: force }),
246 + selectAgentProfile: async (contextId, profileId) => {
247 + calls.push({ endpoint: "selectAgentProfile", payload: { contextId, profileId } });
248 + return true;
249 + },
250 getAgentProfileVisual: (_id, label) => ({ color: "#123456", url: "", initials: label?.[0] || "A" }),
251 };
252 globalThis.window = globalThis;
@@ -298,6 +331,43 @@ store.state.tools.effective_policy = { mode: "inherit", default: "block", allowe
331 store.chooseTools();
332 if (store.draft.toolPolicy.default !== "allow" || store.draft.toolPolicy.blocked.length) throw new Error("inactive inherited exceptions leaked into custom policy");
333 store.projectName = "demo";
334 +store.intent = { ...store.intent, projectName: "demo" };
335 +if (!store.currentChatUsesScope() || !store.isProfileActive("researcher")) throw new Error("active project profile state mismatch");
336 +store.profiles = [{ id: "researcher", title: "Researcher", enabled: true }, { id: "default", title: "Default", enabled: true }];
337 +if (store.activeProfile()?.id !== "researcher") throw new Error("active profile summary mismatch");
338 +await store.setProfileEnabled(store.profiles[0], false);
339 +if (!calls.some(item => item.payload?.action === "set_enabled" && item.payload.profile_id === "researcher") || chatsStore.selectedContext.agent_profile !== "default") throw new Error("profile availability did not reconcile the active chat");
340 +if (!calls.some(item => item.endpoint === "loadAgentProfiles" && item.payload === true)) throw new Error("profile switcher did not refresh eagerly");
341 +let releaseFirstToggle;
342 +const firstToggleResponse = new Promise(resolve => { releaseFirstToggle = resolve; });
343 +let toggleRequest = 0;
344 +setEnabledHandler = () => {
345 + toggleRequest += 1;
346 + return toggleRequest === 1
347 + ? firstToggleResponse
348 + : Promise.resolve({ ok: true });
349 +};
350 +const rapidProfiles = [
351 + { id: "agent0", title: "Agent 0", enabled: true },
352 + { id: "developer", title: "Developer", enabled: true },
353 +];
354 +store.profiles = rapidProfiles;
355 +const firstToggle = store.setProfileEnabled(rapidProfiles[0], false);
356 +while (!toggleRequest) await Promise.resolve();
357 +const secondToggle = store.setProfileEnabled(rapidProfiles[1], false);
358 +await secondToggle;
359 +if (toggleRequest !== 1 || rapidProfiles[1].enabled !== true || !store.profileAvailabilitySaving) throw new Error("availability saves were not serialized");
360 +releaseFirstToggle({ ok: true });
361 +await firstToggle;
362 +if (store.profiles !== rapidProfiles || rapidProfiles[0].enabled || rapidProfiles[1].enabled !== true || store.profileAvailabilitySaving) throw new Error("first availability save did not settle cleanly");
363 +await store.setProfileEnabled(rapidProfiles[1], false);
364 +if (toggleRequest !== 2 || rapidProfiles[1].enabled || store.profileAvailabilitySaving) throw new Error("availability gate did not reopen after save");
365 +setEnabledHandler = null;
366 +await store.duplicateProfile({ id: "researcher", title: "Researcher" });
367 +if (!calls.some(item => item.payload?.action === "duplicate" && item.payload.profile_id === "researcher") || !store.profiles.some(profile => profile.id === "researcher-1")) throw new Error("profile duplication failed");
368 +store.projectName = "other";
369 +if (store.currentChatUsesScope() || store.isProfileActive("default")) throw new Error("foreign project profile appeared active");
370 +store.projectName = "demo";
371 store.state.tools.effective_policy = { mode: "custom", default: "allow", allowed: [], blocked: ["local:shell"] };
372 store.useStandardTools();
373 if (store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope ignored inherited tool restriction");
@@ -381,6 +451,13 @@ await store.applyPendingMutation();
451 if (confirmations.length !== 1 || confirmations[0].type !== "danger") throw new Error("danger confirmation missing");
452 if (!confirmations[0].message.includes("agent.yaml") || !confirmations[0].message.includes("old.md")) throw new Error("planned paths missing from confirmation");
453 if (confirmations[0].title !== "Delete all customizations for this profile?") throw new Error("cleanup confirmation title mismatch");
454 +confirmations.length = 0;
455 +calls.length = 0;
456 +store.projectName = "";
457 +await store.deleteProfile("custom-agent");
458 +if (calls.length) throw new Error("cancelled deletion made an API request");
459 +if (confirmations.length !== 1 || confirmations[0].title !== "Delete custom-agent?") throw new Error("delete confirmation mismatch");
460 +if (confirmations[0].message !== "<p>This agent profile will be permanently deleted from Global and cannot be recovered.</p>") throw new Error("delete confirmation is not concise");
461 """
462 module_source = harness + "\n" + source + "\n" + checks
463 module_url = "data:text/javascript;base64," + base64.b64encode(
tests/test_model_config_api_keys.py
+1
@@ -206,6 +206,7 @@ def test_model_switcher_frontend_renders_custom_overrides():
206 assert "_model_config_override_revision" in refresh_extension_content
207 assert "activeContext?.agent_profile" in refresh_extension_content
208 assert "activeContext?.project" in refresh_extension_content
209 + assert "modelConfigStore.loadAgentProfiles(true)" in refresh_extension_content
210 assert "modelConfigStore.refreshSwitcher(contextId)" in refresh_extension_content
211
212
tests/test_projects.py
+217 -1
@@ -1,6 +1,13 @@
1 +import threading
2 from pathlib import Path
3 +from types import SimpleNamespace
4
3 -from helpers import dirty_json, files, projects
5 +import pytest
6 +
7 +import initialize
8 +from agent import AgentConfig, AgentContext
9 +from helpers import dirty_json, files, persist_chat, projects, subagents
10 +from helpers import state_monitor_integration
11
12
13 def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None:
@@ -10,6 +17,215 @@ def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None:
17 (tmp_path / "plugins").mkdir(parents=True, exist_ok=True)
18
19
20 +@pytest.mark.parametrize(
21 + "destination_project", ["project-y", None], ids=["project", "global"]
22 +)
23 +def test_project_switch_resets_only_profiles_missing_from_the_new_scope(
24 + monkeypatch, destination_project
25 +):
26 + context_id = "ctx-project-profile-switch"
27 + AgentContext.remove(context_id)
28 + context = AgentContext(
29 + config=AgentConfig(mcp_servers="", profile="project-only"),
30 + id=context_id,
31 + set_current=False,
32 + )
33 + monkeypatch.setattr(
34 + projects,
35 + "load_edit_project_data",
36 + lambda name: {"title": name.title(), "color": ""},
37 + )
38 + monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
39 + monkeypatch.setattr(
40 + subagents,
41 + "get_agents_dict",
42 + lambda project_name=None: {
43 + "agent0": subagents.SubAgentListItem(name="agent0"),
44 + **(
45 + {
46 + "project-only": subagents.SubAgentListItem(
47 + name="project-only"
48 + )
49 + }
50 + if project_name == "project-x"
51 + else {}
52 + ),
53 + },
54 + )
55 + monkeypatch.setattr(
56 + initialize,
57 + "initialize_agent",
58 + lambda override_settings=None: AgentConfig(
59 + mcp_servers="",
60 + profile=(override_settings or {}).get("agent_profile", "agent0"),
61 + ),
62 + )
63 +
64 + try:
65 + projects.activate_project(context_id, "project-x", mark_dirty=False)
66 + assert context.config.profile == "project-only"
67 +
68 + if destination_project:
69 + projects.activate_project(
70 + context_id, destination_project, mark_dirty=False
71 + )
72 + else:
73 + projects.deactivate_project(context_id, mark_dirty=False)
74 + assert context.config.profile == "agent0"
75 + assert context.agent0.config.profile == "agent0"
76 + finally:
77 + AgentContext.remove(context_id)
78 +
79 +
80 +def test_project_agent_availability_retains_project_only_profiles(
81 + monkeypatch,
82 +) -> None:
83 + monkeypatch.setattr(
84 + subagents,
85 + "get_agents_dict",
86 + lambda project_name=None: {
87 + "global": subagents.SubAgentListItem(name="global", enabled=True),
88 + **(
89 + {
90 + "project-only": subagents.SubAgentListItem(
91 + name="project-only", enabled=True
92 + )
93 + }
94 + if project_name == "demo"
95 + else {}
96 + ),
97 + },
98 + )
99 +
100 + assert projects._normalize_subagents(
101 + {
102 + "global": {"enabled": True},
103 + "project-only": {"enabled": False},
104 + "missing": {"enabled": False},
105 + },
106 + "demo",
107 + ) == {"project-only": {"enabled": False}}
108 +
109 +
110 +def test_profile_reconciliation_uses_an_available_fallback(monkeypatch) -> None:
111 + context_id = "ctx-profile-availability-fallback"
112 + AgentContext.remove(context_id)
113 + context = AgentContext(
114 + config=AgentConfig(mcp_servers="", profile="disabled"),
115 + id=context_id,
116 + set_current=False,
117 + )
118 + monkeypatch.setattr(
119 + subagents,
120 + "get_available_agents_dict",
121 + lambda _project_name: {
122 + "researcher": subagents.SubAgentListItem(name="researcher")
123 + },
124 + )
125 + monkeypatch.setattr(
126 + initialize,
127 + "initialize_agent",
128 + lambda override_settings=None: AgentConfig(
129 + mcp_servers="",
130 + profile=(override_settings or {}).get("agent_profile", "default"),
131 + ),
132 + )
133 +
134 + try:
135 + assert projects.reconcile_agent_profile(context, None) is True
136 + assert context.config.profile == "researcher"
137 + assert context.agent0.config.profile == "researcher"
138 + finally:
139 + AgentContext.remove(context_id)
140 +
141 +
142 +def test_context_lookup_reconciles_only_new_contexts(monkeypatch) -> None:
143 + from helpers.context_utils import use_context
144 +
145 + existing_id = "ctx-existing-profile"
146 + created_id = "ctx-new-profile"
147 + AgentContext.remove(existing_id)
148 + AgentContext.remove(created_id)
149 + existing = AgentContext(
150 + config=AgentConfig(mcp_servers="", profile="default"),
151 + id=existing_id,
152 + set_current=False,
153 + )
154 + reconciled: list[str] = []
155 + monkeypatch.setattr(
156 + initialize,
157 + "initialize_agent",
158 + lambda: AgentConfig(mcp_servers="", profile="default"),
159 + )
160 + monkeypatch.setattr(
161 + projects,
162 + "reconcile_agent_profile",
163 + lambda context, _project: reconciled.append(context.id),
164 + )
165 +
166 + try:
167 + assert use_context(threading.RLock(), existing_id) is existing
168 + assert reconciled == []
169 +
170 + assert use_context(threading.RLock(), created_id).id == created_id
171 + assert reconciled == [created_id]
172 + finally:
173 + AgentContext.remove(existing_id)
174 + AgentContext.remove(created_id)
175 +
176 +
177 +@pytest.mark.parametrize(
178 + ("all_scopes", "expected"),
179 + [
180 + (False, ["global-changed"]),
181 + (True, ["global-changed", "project-changed"]),
182 + ],
183 +)
184 +def test_bulk_profile_reconciliation_persists_only_changed_chats(
185 + monkeypatch, all_scopes: bool, expected: list[str]
186 +) -> None:
187 + unchanged = SimpleNamespace(id="global-unchanged", project=None)
188 + global_changed = SimpleNamespace(id="global-changed", project=None)
189 + project_changed = SimpleNamespace(id="project-changed", project="demo")
190 + saved: list[str] = []
191 + dirty: list[str] = []
192 + catalog_lookups: list[str | None] = []
193 + monkeypatch.setattr(
194 + AgentContext,
195 + "all",
196 + classmethod(
197 + lambda _cls: [unchanged, global_changed, project_changed]
198 + ),
199 + )
200 + monkeypatch.setattr(
201 + projects, "get_context_project_name", lambda context: context.project
202 + )
203 + monkeypatch.setattr(
204 + projects,
205 + "reconcile_agent_profile",
206 + lambda context, _project, _available: context is not unchanged,
207 + )
208 + monkeypatch.setattr(
209 + subagents,
210 + "get_available_agents_dict",
211 + lambda project: catalog_lookups.append(project) or {},
212 + )
213 + monkeypatch.setattr(
214 + persist_chat, "save_tmp_chat", lambda context: saved.append(context.id)
215 + )
216 + monkeypatch.setattr(
217 + state_monitor_integration,
218 + "mark_dirty_for_context",
219 + lambda context_id, **_kwargs: dirty.append(context_id),
220 + )
221 +
222 + projects.reconcile_agent_profiles(None, all_scopes=all_scopes)
223 +
224 + assert saved == expected
225 + assert dirty == expected
226 + assert catalog_lookups == ([None, "demo"] if all_scopes else [None])
227 +
228 +
229 def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path):
230 _prepare_project_tree(monkeypatch, tmp_path)
231 meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"
tests/test_subagent_metadata_merge.py
+43 -1
@@ -1,6 +1,6 @@
1 from pathlib import Path
2
3 -from helpers import subagents
3 +from helpers import projects, subagents
4
5
6 def _write_profile(root: Path, name: str, metadata: str = "") -> Path:
@@ -76,6 +76,13 @@ def test_nonexistent_layer_is_not_an_override(tmp_path: Path) -> None:
76 )
77
78
79 +def test_bundled_directory_without_definition_is_not_a_profile(tmp_path: Path) -> None:
80 + root = tmp_path / "agents"
81 + _write_profile(root, "_example")
82 +
83 + assert subagents._get_agents_list_from_dir(str(root), "default") == {}
84 +
85 +
86 def test_default_specifics_uses_only_the_canonical_prompt_path() -> None:
87 root = Path(__file__).resolve().parents[1]
88 legacy = root / "agents" / "default" / "agent.system.main.specifics.md"
@@ -90,3 +97,38 @@ def test_default_specifics_uses_only_the_canonical_prompt_path() -> None:
97 assert not legacy.exists()
98 assert canonical.is_file()
99 assert canonical.read_bytes() == b""
100 +
101 +
102 +def test_available_agents_include_project_profiles_and_project_overrides(
103 + monkeypatch,
104 +) -> None:
105 + requested = []
106 + monkeypatch.setattr(
107 + subagents,
108 + "get_agents_dict",
109 + lambda project_name=None: requested.append(project_name) or {
110 + "_example": subagents.SubAgentListItem(
111 + name="_example", enabled=True
112 + ),
113 + "default": subagents.SubAgentListItem(
114 + name="default", enabled=False
115 + ),
116 + "global": subagents.SubAgentListItem(name="global", enabled=True),
117 + "project-only": subagents.SubAgentListItem(
118 + name="project-only", enabled=True
119 + ),
120 + },
121 + )
122 + monkeypatch.setattr(
123 + projects,
124 + "load_project_subagents",
125 + lambda _name: {
126 + "default": {"enabled": False},
127 + "global": {"enabled": False},
128 + },
129 + )
130 +
131 + available = subagents.get_available_agents_dict("demo")
132 +
133 + assert requested == ["demo"]
134 + assert list(available) == ["project-only"]
tests/test_subagent_profiles.py
+13 -5
@@ -5,7 +5,7 @@ from types import SimpleNamespace
5 import pytest
6
7 from agent import Agent, AgentConfig, AgentContext
8 -from helpers import persist_chat
8 +from helpers import persist_chat, projects
9 from helpers.errors import RepairableException
10
11
@@ -182,14 +182,19 @@ def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> Non
182 AgentContext.remove(context_id)
183
184
185 +@pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
186 @pytest.mark.asyncio
186 -async def test_agent_profile_set_preserves_subagent_profile(monkeypatch) -> None:
187 +async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(
188 + monkeypatch, project_name
189 +) -> None:
190 import api.agent_profile_set as agent_profile_set
191
192 + requested_scopes = []
193 monkeypatch.setattr(
190 - agent_profile_set,
191 - "_agent_profile_labels",
192 - lambda: {"researcher": "Researcher"},
194 + agent_profile_set.subagents,
195 + "get_agents_dict",
196 + lambda scope: requested_scopes.append(scope)
197 + or {"researcher": SimpleNamespace(title="Researcher", enabled=True)},
198 )
199 monkeypatch.setattr(
200 agent_profile_set,
@@ -216,6 +221,8 @@ async def test_agent_profile_set_preserves_subagent_profile(monkeypatch) -> None
221 child = Agent(1, AgentConfig(mcp_servers="", profile="developer"), context)
222 context.agent0.set_data(Agent.DATA_NAME_SUBORDINATE, child)
223 child.set_data(Agent.DATA_NAME_SUPERIOR, context.agent0)
224 + if project_name:
225 + context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, project_name)
226
227 try:
228 handler = agent_profile_set.SetAgentProfile.__new__(
@@ -230,5 +237,6 @@ async def test_agent_profile_set_preserves_subagent_profile(monkeypatch) -> None
237 assert context.config.profile == "researcher"
238 assert context.agent0.config.profile == "researcher"
239 assert child.config.profile == "developer"
240 + assert requested_scopes == [project_name]
241 finally:
242 AgentContext.remove(context_id)