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)