fix agent profile switching logic
Keep profile catalog reads lightweight by detecting sparse editor overrides without building removal plans for every agent. Use the chat snapshot refresh as the single switch trigger and share concurrent same-context catalog requests while preserving newest-context-wins behavior.
Alessandro committed
Aug 11, 2026 at 23:19 UTC
282f13982b7a4f4d50b0150074d007f13c85aafb
8 files changed
+136
-46
plugins/_agent_editor/AGENTS.md
+2
@@ -16,6 +16,8 @@
16
## Local Contracts
17
18
- The editor performs zero model calls.
19
+- Profile list requests stay lightweight: summary rows inspect only sparse
20
+ editor-owned keys and files and never construct full save or removal plans.
21
- Writes are limited to the selected profile layer — global
22
`usr/agents/<profile-id>` or project
23
`usr/projects/<project>/.a0proj/agents/<profile-id>` — and only to paths or
plugins/_agent_editor/helpers/editor.py
+50
-8
@@ -216,10 +216,7 @@ def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
216
"origin": state["origin"],
217
"origin_chain": state["origin_chain"],
218
"built_in": state["built_in"],
219
- "scope_has_overrides": bool(
220
- profile_exists(profile_id, context)
221
- and plan_remove_changes(profile_id, context).changes
222
- ),
219
+ "scope_has_overrides": _scope_has_overrides(profile_id, context),
220
"deletable": state["deletable"],
221
"avatar": state["avatar"]["effective"],
222
"avatar_url": effective_avatar_url(profile_id, context),
@@ -345,10 +342,7 @@ def build_profile_state(
342
"origin": metadata.pop("origin"),
343
"origin_chain": metadata.pop("origin_chain"),
344
"built_in": metadata.pop("built_in"),
348
- "scope_has_overrides": bool(
349
- profile_exists(profile_id, context)
350
- and plan_remove_changes(profile_id, context).changes
351
- ),
345
+ "scope_has_overrides": _scope_has_overrides(profile_id, context),
346
"deletable": metadata.pop("deletable"),
347
"metadata": metadata,
348
"avatar_url": effective_avatar_url(profile_id, context),
@@ -391,6 +385,54 @@ def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any
385
return state
386
387
388
+def _scope_has_overrides(profile_id: str, context: Any | None = None) -> bool:
389
+ project_name = _context_project_name(context)
390
+ root = _profile_root(profile_id, project_name)
391
+ metadata = _read_mapping(
392
+ root / "agent.yaml" if (root / "agent.yaml").is_file() else root / "agent.json"
393
+ )
394
+ if any(key in metadata for key in METADATA_KEYS):
395
+ return True
396
+
397
+ scope_prompts = root / "prompts"
398
+ if scope_prompts.is_dir():
399
+ inherited_roots = [
400
+ directory / "prompts"
401
+ for _, directory in _profile_directories(profile_id, context)
402
+ if directory.absolute() != root.absolute()
403
+ ]
404
+ inherited_roots.append(Path(files.get_abs_path("prompts")))
405
+ if any(
406
+ path.name not in NON_PROMPT_MARKDOWN
407
+ and any((inherited / path.name).is_file() for inherited in inherited_roots)
408
+ for path in scope_prompts.glob("*.md")
409
+ if path.is_file()
410
+ ):
411
+ return True
412
+
413
+ from plugins._model_config.helpers import model_config
414
+
415
+ checks = (
416
+ (
417
+ _profile_config_path(profile_id, "_model_config", project_name),
418
+ (model_config.MODEL_PRESET_CONFIG_KEY,),
419
+ ),
420
+ (
421
+ _profile_config_path(profile_id, tool_policy.PLUGIN_NAME, project_name),
422
+ _TOOL_POLICY_KEYS,
423
+ ),
424
+ (
425
+ _profile_config_path(
426
+ profile_id,
427
+ skills.ACTIVE_SKILLS_PLUGIN_NAME,
428
+ project_name,
429
+ ),
430
+ ("visibility_policy",),
431
+ ),
432
+ )
433
+ return any(any(key in _read_mapping(path) for key in keys) for path, keys in checks)
434
+
435
+
436
def prompt_catalog(agent: EditorAgent) -> list[dict[str, Any]]:
437
roots = [Path(path) for path in subagents.get_paths(agent, "prompts")]
438
names = {
plugins/_model_config/AGENTS.md
+3
-2
@@ -35,8 +35,9 @@
35
current status without adding a selectable or editable row.
36
- Reload the agent-profile selector catalog when a chat changes project or
37
active profile so project-only profiles never linger in the visible choices.
38
-- When forced agent-profile catalog loads overlap, only the newest request may
39
- replace selector state or finish its loading lifecycle.
38
+- Concurrent agent-profile catalog loads for the same chat share one request;
39
+ across chats, only the newest request may replace selector state or finish
40
+ its loading lifecycle.
41
- Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults.
42
- Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`.
43
- 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/chat-input-progress-start/model-switcher.html
+1
-4
@@ -11,10 +11,7 @@
11
$store.modelConfig.refreshSwitcher($store.chats?.selected || ''),
12
$store.modelConfig.loadAgentProfiles(),
13
]);
14
- $watch('$store.chats.selected', v => Promise.all([
15
- $store.modelConfig.refreshSwitcher(v || ''),
16
- $store.modelConfig.loadAgentProfiles(true),
17
- ]));
14
+ $watch('$store.chats.selected', v => $store.modelConfig.refreshSwitcher(v || ''));
15
">
16
<template x-if="($store.modelConfig.switcherAllowed && !$store.modelConfig.switcherLoading) || $store.chats?.selectedContext?.agent_profile">
17
<div class="model-switcher-container">
plugins/_model_config/webui/switcher-mixin.js
+39
-24
@@ -38,38 +38,53 @@ export const switcherState = {
38
agentProfileSaving: false,
39
};
40
41
+let agentProfilesRequest = null;
42
+let agentProfilesRequestContext = "";
43
+
44
export const switcherMethods = {
45
async loadAgentProfiles(force = false) {
46
+ const contextId = window.Alpine?.store("chats")?.selected || "";
47
+ if (agentProfilesRequest && agentProfilesRequestContext === contextId) {
48
+ return agentProfilesRequest;
49
+ }
50
if (!force && this.agentProfilesLoaded) return this.agentProfiles;
51
const requestSeq = ++this.agentProfilesLoadSeq;
52
this.agentProfilesLoading = true;
53
+ const request = (async () => {
54
+ try {
55
+ const data = await callJsonApi("/plugins/_agent_editor/agent_editor", {
56
+ action: "list",
57
+ context_id: contextId,
58
+ });
59
+ if (requestSeq !== this.agentProfilesLoadSeq) return this.agentProfiles;
60
+ this.agentProfiles = (data.profiles || [])
61
+ .filter(profile => profile.id && !["_example", "default"].includes(profile.id) && profile.enabled !== false)
62
+ .map(profile => ({
63
+ key: profile.id,
64
+ label: profile.title || profile.id,
65
+ avatar: profile.avatar || null,
66
+ avatarUrl: profile.avatar_url || "",
67
+ }));
68
+ this.agentProfilesLoaded = true;
69
+ } catch (e) {
70
+ if (requestSeq !== this.agentProfilesLoadSeq) return this.agentProfiles;
71
+ console.error("Agent profile list load failed:", e);
72
+ this.agentProfiles = [];
73
+ this.agentProfilesLoaded = false;
74
+ } finally {
75
+ if (requestSeq === this.agentProfilesLoadSeq) {
76
+ this.agentProfilesLoading = false;
77
+ }
78
+ }
79
+ return this.agentProfiles;
80
+ })();
81
+ agentProfilesRequest = request;
82
+ agentProfilesRequestContext = contextId;
83
try {
47
- const contextId = window.Alpine?.store("chats")?.selected || "";
48
- const data = await callJsonApi("/plugins/_agent_editor/agent_editor", {
49
- action: "list",
50
- context_id: contextId,
51
- });
52
- if (requestSeq !== this.agentProfilesLoadSeq) return this.agentProfiles;
53
- this.agentProfiles = (data.profiles || [])
54
- .filter(profile => profile.id && !["_example", "default"].includes(profile.id) && profile.enabled !== false)
55
- .map(profile => ({
56
- key: profile.id,
57
- label: profile.title || profile.id,
58
- avatar: profile.avatar || null,
59
- avatarUrl: profile.avatar_url || "",
60
- }));
61
- this.agentProfilesLoaded = true;
62
- } catch (e) {
63
- if (requestSeq !== this.agentProfilesLoadSeq) return this.agentProfiles;
64
- console.error("Agent profile list load failed:", e);
65
- this.agentProfiles = [];
66
- this.agentProfilesLoaded = false;
84
+ return await request;
85
} finally {
68
- if (requestSeq === this.agentProfilesLoadSeq) {
69
- this.agentProfilesLoading = false;
70
- }
86
+ if (agentProfilesRequest === request) agentProfilesRequest = null;
87
}
72
- return this.agentProfiles;
88
},
89
90
async loadSwitcherState(contextId) {
tests/test_agent_editor.py
+24
@@ -395,6 +395,30 @@ def test_model_and_off_tool_choices_write_only_their_json_contracts(
395
}
396
397
398
+def test_profile_summaries_do_not_build_removal_plans(
399
+ user_root: Path,
400
+ monkeypatch: pytest.MonkeyPatch,
401
+) -> None:
402
+ profile = user_root / "researcher"
403
+ (profile / "plugins" / "manual").mkdir(parents=True)
404
+ (profile / "plugins" / "manual" / "config.json").write_text("{}")
405
+ monkeypatch.setattr(
406
+ editor,
407
+ "plan_remove_changes",
408
+ lambda *_args, **_kwargs: pytest.fail("profile summaries built a removal plan"),
409
+ )
410
+
411
+ assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
412
+ assert next(
413
+ item for item in editor.list_profiles() if item["id"] == "researcher"
414
+ )["scope_has_overrides"] is False
415
+
416
+ prompts = profile / "prompts"
417
+ prompts.mkdir()
418
+ (prompts / editor.SPECIFICS_FILE).write_text("Scoped instructions")
419
+ assert editor.build_profile_state("researcher")["scope_has_overrides"] is True
420
+
421
+
422
def test_project_tool_policy_reads_effective_access_and_writes_project_scope(
423
project_scope: tuple[editor._EditorContext, Path],
424
monkeypatch: pytest.MonkeyPatch,
tests/test_agent_editor_webui.py
+16
-8
@@ -806,30 +806,38 @@ if (store.view !== "manage" || store.mode !== "easy" || store.draft !== null ||
806
807
808
@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
809
-def test_latest_agent_profile_load_owns_switcher_state() -> None:
809
+def test_agent_profile_loads_dedupe_same_context_and_ignore_stale_context() -> None:
810
source = SWITCHER_MIXIN.read_text(encoding="utf-8")
811
source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
812
harness = r"""
813
const pending = [];
814
const callJsonApi = async () => await new Promise(resolve => pending.push(resolve));
815
const fetchApi = async () => ({ ok: true, json: async () => ({}) });
816
-globalThis.window = { Alpine: { store: () => ({ selected: "ctx" }) } };
816
+let selectedContextId = "ctx";
817
+globalThis.window = { Alpine: { store: () => ({ selected: selectedContextId }) } };
818
"""
819
checks = r"""
820
const store = { ...switcherState, ...switcherMethods };
821
const older = store.loadAgentProfiles(true);
822
const newer = store.loadAgentProfiles(true);
822
-if (pending.length !== 2 || !store.agentProfilesLoading) throw new Error("overlapping profile loads did not start");
823
-pending[1]({ profiles: [
823
+if (pending.length !== 1 || !store.agentProfilesLoading) throw new Error("same-context profile loads were not deduplicated");
824
+pending[0]({ profiles: [
825
{ id: "default", title: "Default", enabled: true },
826
{ id: "new", title: "New", enabled: true },
827
] });
827
-await newer;
828
+await Promise.all([older, newer]);
829
if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("newest profile load did not settle");
830
if (store.agentProfiles.length !== 1 || store.getAgentProfileList("default", "Default").some(profile => profile.key === "default")) throw new Error("Default profile remained selectable in the chat popover");
830
-pending[0]({ profiles: [{ id: "old", title: "Old", enabled: true }] });
831
-await older;
832
-if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("stale profile load replaced newer state");
831
+selectedContextId = "older-context";
832
+const stale = store.loadAgentProfiles(true);
833
+selectedContextId = "newer-context";
834
+const fresh = store.loadAgentProfiles(true);
835
+if (pending.length !== 3) throw new Error("different-context profile loads were incorrectly deduplicated");
836
+pending[2]({ profiles: [{ id: "fresh", title: "Fresh", enabled: true }] });
837
+await fresh;
838
+pending[1]({ profiles: [{ id: "stale", title: "Stale", enabled: true }] });
839
+await stale;
840
+if (store.agentProfiles[0]?.key !== "fresh" || store.agentProfilesLoading) throw new Error("stale profile load replaced newer state");
841
const requestCount = pending.length;
842
await store.loadAgentProfiles();
843
if (pending.length !== requestCount) throw new Error("cached profile catalog unexpectedly reloaded");
tests/test_model_config_api_keys.py
+1
@@ -201,6 +201,7 @@ def test_model_switcher_frontend_renders_custom_overrides():
201
assert "normalizeModelIdentity(o.chat || o)" in switcher_content
202
assert "normalizeModelIdentity(o.utility)" in switcher_content
203
assert "$store.modelConfig.getSwitcherLabel()" in switcher_html
204
+ assert "loadAgentProfiles(true)" not in switcher_html
205
assert "model-switcher-active-pills" not in switcher_html
206
assert "model-pill-role" not in switcher_html
207
assert "_model_config_override_revision" in refresh_extension_content