Stabilize Agent Editor client workflows
Rebase new-agent drafts when scope changes, recompute Review paths, preserve authored creation metadata, and align client validation with backend prompt requirements. Guard pending removals and stale manager transitions from discarding edits, let only the newest profile-catalog request own selector state, and hide stale rows during refresh. Finish the themed, responsive Easy and Advanced presentation and exercise the real Alpine stores with controlled state and request-order regressions.
Alessandro committed
Aug 10, 2026 at 05:08 UTC
b5ab1d5b8f37b16638a1e5b288729c10ddf97591
6 files changed
+346
-34
plugins/_agent_editor/webui/agent-editor-store.js
+126
-12
@@ -306,6 +306,49 @@ const model = {
306
this.projectName = previous;
307
return;
308
}
309
+ const reviewActive = this.mode === "advanced" && this.section === "5";
310
+ const creatingDraft = this.draft?.creating ? this.draft : null;
311
+ const creatingInitial = creatingDraft ? this.initialDraft : null;
312
+ const creatingState = creatingDraft ? this.state : null;
313
+ const selectedPrompt = this.selectedPrompt;
314
+ const comparePrompt = this.comparePrompt;
315
+ const promptChanges = creatingDraft
316
+ ? Object.values(creatingDraft.prompts).flatMap((prompt) => {
317
+ const initial = creatingInitial?.prompts?.[prompt.filename];
318
+ if (!initial || prompt.reset || prompt.value === initial.value) return [];
319
+ const baseline = this.promptEditBaselines[prompt.filename];
320
+ const initialBaseline = { value: initial.value, reset: initial.reset };
321
+ return [{
322
+ filename: prompt.filename,
323
+ value: prompt.value,
324
+ baseline: baseline && !same(baseline, initialBaseline) ? clone(baseline) : null,
325
+ }];
326
+ })
327
+ : [];
328
+ const policyChanges = [];
329
+ if (creatingDraft) {
330
+ for (const [kind, key, idKey] of [
331
+ ["tools", "toolPolicy", "id"],
332
+ ["skills", "skillPolicy", "name"],
333
+ ]) {
334
+ const policy = creatingDraft[key];
335
+ if (policy.mode !== "custom" || same(policy, creatingInitial[key])) continue;
336
+ const oldState = creatingState[kind];
337
+ policyChanges.push({
338
+ kind: kind === "tools" ? "tool" : "skill",
339
+ key,
340
+ idKey,
341
+ default: policy.default,
342
+ choices: (oldState.catalog || [])
343
+ .map((item) => [
344
+ item[idKey],
345
+ policyAllows(policy, item[idKey]),
346
+ policyAllows(oldState.effective_policy, item[idKey]),
347
+ ])
348
+ .filter(([, allowed, inherited]) => allowed !== inherited),
349
+ });
350
+ }
351
+ }
352
this.intent = { ...this.intent, projectName: next };
353
this.loading = true;
354
this.error = "";
@@ -325,7 +368,48 @@ const model = {
368
...this.scopeInput(),
369
});
370
this.state = data.state;
328
- if (!this.draft.creating) this.makeDraft(false);
371
+ if (!creatingDraft) {
372
+ this.makeDraft(false);
373
+ } else {
374
+ const avatarChanged = !same(
375
+ [creatingDraft.avatar, creatingDraft.avatarToken, creatingDraft.metadataResets.includes("avatar")],
376
+ [creatingInitial.avatar, creatingInitial.avatarToken, creatingInitial.metadataResets.includes("avatar")],
377
+ );
378
+ const modelPresetChanged = creatingDraft.modelPreset !== creatingInitial.modelPreset;
379
+ this.makeDraft(true);
380
+ this.draft.profileId = creatingDraft.profileId;
381
+ this.draft.title = creatingDraft.title;
382
+ this.draft.description = creatingDraft.description;
383
+ this.draft.context = creatingDraft.context;
384
+ for (const change of promptChanges) {
385
+ const prompt = this.draft.prompts[change.filename];
386
+ if (!prompt) continue;
387
+ prompt.value = change.value;
388
+ prompt.reset = false;
389
+ if (change.baseline) this.promptEditBaselines[change.filename] = change.baseline;
390
+ }
391
+ if (this.draft.prompts[selectedPrompt]) this.selectedPrompt = selectedPrompt;
392
+ this.comparePrompt = comparePrompt;
393
+ if (avatarChanged) {
394
+ this.draft.avatar = clone(creatingDraft.avatar);
395
+ this.draft.avatarToken = creatingDraft.avatarToken;
396
+ this.draft.avatarPreview = creatingDraft.avatarPreview;
397
+ if (creatingDraft.metadataResets.includes("avatar")) {
398
+ this.draft.metadataResets.push("avatar");
399
+ }
400
+ }
401
+ if (modelPresetChanged) this.draft.modelPreset = creatingDraft.modelPreset;
402
+ for (const change of policyChanges) {
403
+ this.customizePolicy(change.kind);
404
+ this.setPolicyDefault(change.kind, change.default);
405
+ const catalog = this.state[change.kind === "tool" ? "tools" : "skills"].catalog || [];
406
+ const nextIds = new Set(catalog.map((item) => item[change.idKey]));
407
+ for (const [id, allowed] of change.choices) {
408
+ if (nextIds.has(id)) movePolicyItem(this.draft[change.key], id, allowed);
409
+ }
410
+ }
411
+ }
412
+ if (reviewActive) await this.previewPlan();
413
} catch (error) {
414
if (this.view === "editor" && this.projectName !== previous) {
415
this.projectName = previous;
@@ -500,6 +584,20 @@ const model = {
584
inner?.classList.toggle("agent-editor-easy", this.mode !== "advanced");
585
},
586
587
+ enterManager() {
588
+ this.revokePreview();
589
+ this.draft = null;
590
+ this.initialDraft = null;
591
+ this.promptEditBaselines = {};
592
+ this.pendingMutation = null;
593
+ this.plan = { written: [], deleted: [], warnings: [] };
594
+ this.planStatus = "idle";
595
+ this.view = "manage";
596
+ this.mode = "easy";
597
+ this.syncSurface();
598
+ this.setModalTitle("Manage agents");
599
+ },
600
+
601
setMode(mode, section = "", preview = true) {
602
this.mode = mode === "advanced" ? "advanced" : "easy";
603
if (section) this.setSection(section, preview);
@@ -634,6 +732,7 @@ const model = {
732
},
733
734
metadataProvenance(key) {
735
+ if (this.draft?.creating) return "";
736
const metadata = this.state?.profile?.metadata?.[key] || {};
737
if (metadata.has_override && !this.metadataResetPending(key)) return "Customized by you";
738
if (this.projectName) return "Inherited from Global";
@@ -942,9 +1041,16 @@ const model = {
1041
if (this.profileConflict) {
1042
issues.push({ key: "name", section: "1", field: "agent-editor-advanced-name", label: "Agent name", message: `An agent with profile ID ${this.draft.profileId} already exists.` });
1043
}
945
- if (!this.instructions?.value.trim()) {
946
- issues.push({ key: "instructions", section: "2", field: "agent-editor-prompt-text", label: "Instructions", message: "Instructions are required for a new agent." });
947
- }
1044
+ }
1045
+ const instructions = this.instructions;
1046
+ if (!instructions?.reset && !instructions?.value.trim()
1047
+ && (this.draft?.creating
1048
+ || (this.mode === "easy" && instructions?.value !== instructions?.initialValue))) {
1049
+ const fallback = this.projectName ? "inherited" : "default";
1050
+ const message = this.draft.creating
1051
+ ? "Instructions are required for a new agent."
1052
+ : `Instructions can’t be empty. Use ${fallback} instructions instead.`;
1053
+ issues.push({ key: "instructions", section: "2", field: "agent-editor-prompt-text", label: "Instructions", message });
1054
}
1055
if (this.avatarUploading) {
1056
issues.push({ key: "avatar", section: "1", field: "agent-editor-advanced-name", label: "Avatar", message: "Wait for the avatar upload to finish." });
@@ -978,7 +1084,8 @@ const model = {
1084
};
1085
const metadata = { set: {}, reset: unique(this.draft.metadataResets) };
1086
for (const key of ["title", "description", "context"]) {
981
- if (this.draft.creating ? key === "title" : this.draft[key] !== this.initialDraft[key]) {
1087
+ if ((this.draft.creating && key === "title")
1088
+ || this.draft[key] !== this.initialDraft[key]) {
1089
if (!metadata.reset.includes(key)) metadata.set[key] = this.draft[key];
1090
}
1091
}
@@ -1052,7 +1159,7 @@ const model = {
1159
},
1160
1161
async save(test = false) {
1055
- if (this.saving || !this.draft) return false;
1162
+ if (this.saving || !this.draft || this.pendingMutation) return false;
1163
const errors = this.validationErrors();
1164
if (errors.length) {
1165
this.error = "";
@@ -1132,6 +1239,10 @@ const model = {
1239
},
1240
1241
async planRemoval(destructive = false) {
1242
+ if (this.dirty) {
1243
+ this.error = "Save or discard your changes before removing customizations.";
1244
+ return false;
1245
+ }
1246
this.planLoading = true;
1247
this.error = "";
1248
try {
@@ -1154,6 +1265,10 @@ const model = {
1265
1266
async applyPendingMutation() {
1267
if (!this.pendingMutation) return;
1268
+ if (this.dirty) {
1269
+ this.error = "Save or discard your changes before applying the removal plan.";
1270
+ return false;
1271
+ }
1272
const planned = [
1273
["Will update", this.plan.written || []],
1274
["Will delete", this.plan.deleted || []],
@@ -1173,6 +1288,7 @@ const model = {
1288
action: "remove_changes",
1289
profile_id: this.draft.profileId,
1290
destructive: this.pendingMutation.destructive,
1291
+ confirm: this.pendingMutation.destructive ? true : undefined,
1292
...this.scopeInput(),
1293
});
1294
this.pendingMutation = null;
@@ -1184,6 +1300,7 @@ const model = {
1300
},
1301
1302
async deleteProfile(profileId) {
1303
+ this.error = "";
1304
try {
1305
const confirmed = await showConfirmDialog({
1306
title: `Delete ${escapeHtml(profileId)}?`,
@@ -1200,8 +1317,7 @@ const model = {
1317
});
1318
await this.loadProfiles();
1319
await modelConfigStore.loadAgentProfiles(true);
1203
- this.view = "manage";
1204
- this.setModalTitle("Manage agents");
1320
+ this.enterManager();
1321
globalThis.justToast?.(`Agent deleted from ${this.scopeLabel}.`, "success", 1800);
1322
} catch (error) {
1323
this.error = error.message || String(error);
@@ -1210,10 +1326,8 @@ const model = {
1326
1327
showManager() {
1328
if (this.dirty && !window.confirm("You have unsaved changes that will be lost. Continue?")) return;
1213
- this.view = "manage";
1214
- this.mode = "easy";
1215
- this.syncSurface();
1216
- this.setModalTitle("Manage agents");
1329
+ this.error = "";
1330
+ this.enterManager();
1331
this.loadProfiles();
1332
},
1333
};
plugins/_agent_editor/webui/main.html
+18
-16
@@ -159,7 +159,7 @@
159
160
<section class="agent-easy-field agent-easy-tools">
161
<div class="agent-field-heading">
162
- <div><div class="agent-field-label">Tools</div><p>Choose which tools this agent can use.</p></div>
162
+ <div><div class="agent-field-label">Tools <span class="field-count" x-text="`— ${$store.agentEditor.toolCatalog.length} ${$store.agentEditor.toolCatalog.length === 1 ? 'tool' : 'tools'}`"></span></div><p>Choose which tools this agent can use.</p></div>
163
</div>
164
<div class="easy-tool-list" role="list" aria-label="Tools this agent can use">
165
<template x-for="tool in $store.agentEditor.toolCatalog" :key="tool.id">
@@ -202,9 +202,9 @@
202
</div>
203
</div>
204
<div class="identity-fields">
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>
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-show="$store.agentEditor.metadataProvenance('title')" 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>
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-show="$store.agentEditor.metadataProvenance('description')" 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">
@@ -218,7 +218,7 @@
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>
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-show="$store.agentEditor.metadataProvenance('context')" 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>
224
</section>
@@ -270,14 +270,14 @@
270
271
<section x-show="$store.agentEditor.section === '3'" data-agent-editor-section="3" tabindex="-1" aria-labelledby="agent-section-3-title">
272
<header class="advanced-section-heading"><h3 id="agent-section-3-title">Tools</h3><p>Choose which tools this agent can use.</p></header>
273
- <div class="policy-mode-row"><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardTools()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited tool access' : 'Use standard tool access'"></span> <small x-text="`(${$store.agentEditor.toolCatalog.length} tools)`"></small></span></label><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'custom'" @change="$store.agentEditor.chooseTools()">Choose tools</label></div>
273
+ <div class="policy-mode-row"><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardTools()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited tool access' : 'Use standard tool access'"></span> <small x-text="`(${$store.agentEditor.toolCatalog.length} ${$store.agentEditor.toolCatalog.length === 1 ? 'tool' : 'tools'})`"></small></span></label><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'custom'" @change="$store.agentEditor.chooseTools()">Choose tools</label></div>
274
<fieldset class="policy-editor" :disabled="$store.agentEditor.draft.toolPolicy.mode !== 'custom'">
275
<legend class="sr-only">Tool access selection</legend>
276
<div class="policy-filters tools"><label class="policy-search"><x-icon name="search"></x-icon><span class="sr-only">Search tools</span><input type="search" x-model="$store.agentEditor.toolSearch" placeholder="Search tools"></label><label>Category <select x-model="$store.agentEditor.toolCategory"><option value="all">All</option><option value="local">Local</option><option value="plugin">Plugin</option><option value="mcp">MCP</option></select></label><label>Origin <select x-model="$store.agentEditor.toolOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.toolOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
277
<div class="policy-lists">
278
- <section class="policy-list" aria-labelledby="allowed-tools-title"><header><div><h4 id="allowed-tools-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredTools(true).length} tools`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.toolPolicy.mode === 'custom' && $store.agentEditor.filteredTools(true).length" @click="$store.agentEditor.moveAllVisibleTools(false)" x-text="`Block ${$store.agentEditor.filteredTools(true).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools(true).length">No allowed tools — select items on the right, or allow new tools automatically.</p><template x-for="tool in $store.agentEditor.filteredTools(true)" :key="tool.id"><div class="policy-item"><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedAllowedTools" :aria-label="`Select ${tool.label}`"><div><strong x-text="tool.label"></strong><small x-text="tool.id"></small><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p><em x-show="!tool.available">Unavailable — kept in your settings</em></div></div></template></div></section>
278
+ <section class="policy-list" aria-labelledby="allowed-tools-title"><header><div><h4 id="allowed-tools-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredTools(true).length} ${$store.agentEditor.filteredTools(true).length === 1 ? 'tool' : 'tools'}`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.toolPolicy.mode === 'custom' && $store.agentEditor.filteredTools(true).length" @click="$store.agentEditor.moveAllVisibleTools(false)" x-text="`Block ${$store.agentEditor.filteredTools(true).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools(true).length">No allowed tools — select items on the right, or allow new tools automatically.</p><template x-for="tool in $store.agentEditor.filteredTools(true)" :key="tool.id"><div class="policy-item"><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedAllowedTools" :aria-label="`Select ${tool.label}`"><div><strong x-text="tool.label"></strong><small x-text="tool.id"></small><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p><em x-show="!tool.available">Unavailable — kept in your settings</em></div></div></template></div></section>
279
<div class="policy-transfer-actions" role="group" aria-label="Move selected tools"><button type="button" class="button icon" :disabled="!$store.agentEditor.selectedAllowedTools.length" aria-label="Block selected tools" @click="$store.agentEditor.moveTools($store.agentEditor.selectedAllowedTools, false)"><x-icon name="arrow_forward"></x-icon></button><button type="button" class="button icon" :disabled="!$store.agentEditor.selectedBlockedTools.length" aria-label="Allow selected tools" @click="$store.agentEditor.moveTools($store.agentEditor.selectedBlockedTools, true)"><x-icon name="arrow_back"></x-icon></button></div>
280
- <section class="policy-list" aria-labelledby="blocked-tools-title"><header><div><h4 id="blocked-tools-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredTools(false).length} tools`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.toolPolicy.mode === 'custom' && $store.agentEditor.filteredTools(false).length" @click="$store.agentEditor.moveAllVisibleTools(true)" x-text="`Allow ${$store.agentEditor.filteredTools(false).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools(false).length" x-text="$store.agentEditor.draft.toolPolicy.mode === 'custom' ? 'No blocked tools — select items on the left, or block new tools until reviewed.' : 'Choose tools to customize standard access.'"></p><template x-for="tool in $store.agentEditor.filteredTools(false)" :key="tool.id"><div class="policy-item"><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedBlockedTools" :aria-label="`Select ${tool.label}`"><div><strong x-text="tool.label"></strong><small x-text="tool.id"></small><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p><em x-show="!tool.available">Unavailable — kept in your settings</em></div></div></template></div></section>
280
+ <section class="policy-list" aria-labelledby="blocked-tools-title"><header><div><h4 id="blocked-tools-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredTools(false).length} ${$store.agentEditor.filteredTools(false).length === 1 ? 'tool' : 'tools'}`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.toolPolicy.mode === 'custom' && $store.agentEditor.filteredTools(false).length" @click="$store.agentEditor.moveAllVisibleTools(true)" x-text="`Allow ${$store.agentEditor.filteredTools(false).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools(false).length" x-text="$store.agentEditor.draft.toolPolicy.mode === 'custom' ? 'No blocked tools — select items on the left, or block new tools until reviewed.' : 'Choose tools to customize standard access.'"></p><template x-for="tool in $store.agentEditor.filteredTools(false)" :key="tool.id"><div class="policy-item"><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedBlockedTools" :aria-label="`Select ${tool.label}`"><div><strong x-text="tool.label"></strong><small x-text="tool.id"></small><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p><em x-show="!tool.available">Unavailable — kept in your settings</em></div></div></template></div></section>
281
</div>
282
<fieldset class="future-default"><legend>When new tools are installed later</legend><label><input type="radio" name="tool-future-default" value="allow" :checked="$store.agentEditor.draft.toolPolicy.default === 'allow'" @change="$store.agentEditor.setPolicyDefault('tool','allow')">Allow automatically</label><label><input type="radio" name="tool-future-default" value="block" :checked="$store.agentEditor.draft.toolPolicy.default === 'block'" @change="$store.agentEditor.setPolicyDefault('tool','block')">Block until reviewed</label></fieldset>
283
</fieldset>
@@ -285,14 +285,14 @@
285
286
<section x-show="$store.agentEditor.section === '4'" data-agent-editor-section="4" tabindex="-1" aria-labelledby="agent-section-4-title">
287
<header class="advanced-section-heading"><h3 id="agent-section-4-title">Skills</h3><p>Choose which skills this agent can find and use.</p></header>
288
- <div class="policy-mode-row"><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardSkills()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited skill access' : 'Use standard skill access'"></span> <small x-text="`(${$store.agentEditor.skillCatalog.length} skills)`"></small></span></label><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'custom'" @change="$store.agentEditor.chooseSkills()">Choose skills</label></div>
288
+ <div class="policy-mode-row"><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardSkills()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited skill access' : 'Use standard skill access'"></span> <small x-text="`(${$store.agentEditor.skillCatalog.length} ${$store.agentEditor.skillCatalog.length === 1 ? 'skill' : 'skills'})`"></small></span></label><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'custom'" @change="$store.agentEditor.chooseSkills()">Choose skills</label></div>
289
<fieldset class="policy-editor" :disabled="$store.agentEditor.draft.skillPolicy.mode !== 'custom'">
290
<legend class="sr-only">Skill access selection</legend>
291
<div class="policy-filters skills"><label class="policy-search"><x-icon name="search"></x-icon><span class="sr-only">Search skills</span><input type="search" x-model="$store.agentEditor.skillSearch" placeholder="Search skills"></label><label>Origin <select x-model="$store.agentEditor.skillOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.skillOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
292
<div class="policy-lists">
293
- <section class="policy-list" aria-labelledby="allowed-skills-title"><header><div><h4 id="allowed-skills-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredSkills(true).length} skills`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.skillPolicy.mode === 'custom' && $store.agentEditor.filteredSkills(true).length" @click="$store.agentEditor.moveAllVisibleSkills(false)" x-text="`Block ${$store.agentEditor.filteredSkills(true).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredSkills(true).length">No allowed skills — select items on the right, or allow new skills automatically.</p><template x-for="skill in $store.agentEditor.filteredSkills(true)" :key="skill.path"><div class="policy-item"><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedAllowedSkills" :aria-label="`Select ${skill.name}`"><div><strong x-text="skill.name"></strong><small x-text="skill.origin"></small><p class="policy-item-description" x-show="skill.description" x-text="skill.description"></p><em x-show="skill.available === false">Unavailable — kept in your settings</em><em x-show="$store.agentEditor.skillWarnings(skill).length" x-text="`Expects blocked tool: ${$store.agentEditor.skillWarnings(skill).join(', ')}`"></em></div></div></template></div></section>
293
+ <section class="policy-list" aria-labelledby="allowed-skills-title"><header><div><h4 id="allowed-skills-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredSkills(true).length} ${$store.agentEditor.filteredSkills(true).length === 1 ? 'skill' : 'skills'}`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.skillPolicy.mode === 'custom' && $store.agentEditor.filteredSkills(true).length" @click="$store.agentEditor.moveAllVisibleSkills(false)" x-text="`Block ${$store.agentEditor.filteredSkills(true).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredSkills(true).length">No allowed skills — select items on the right, or allow new skills automatically.</p><template x-for="skill in $store.agentEditor.filteredSkills(true)" :key="skill.path"><div class="policy-item"><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedAllowedSkills" :aria-label="`Select ${skill.name}`"><div><strong x-text="skill.name"></strong><small x-text="skill.origin"></small><p class="policy-item-description" x-show="skill.description" x-text="skill.description"></p><em x-show="skill.available === false">Unavailable — kept in your settings</em><em x-show="$store.agentEditor.skillWarnings(skill).length" x-text="`Expects blocked tool: ${$store.agentEditor.skillWarnings(skill).join(', ')}`"></em></div></div></template></div></section>
294
<div class="policy-transfer-actions" role="group" aria-label="Move selected skills"><button type="button" class="button icon" :disabled="!$store.agentEditor.selectedAllowedSkills.length" aria-label="Block selected skills" @click="$store.agentEditor.moveSkills($store.agentEditor.selectedAllowedSkills, false)"><x-icon name="arrow_forward"></x-icon></button><button type="button" class="button icon" :disabled="!$store.agentEditor.selectedBlockedSkills.length" aria-label="Allow selected skills" @click="$store.agentEditor.moveSkills($store.agentEditor.selectedBlockedSkills, true)"><x-icon name="arrow_back"></x-icon></button></div>
295
- <section class="policy-list" aria-labelledby="blocked-skills-title"><header><div><h4 id="blocked-skills-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredSkills(false).length} skills`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.skillPolicy.mode === 'custom' && $store.agentEditor.filteredSkills(false).length" @click="$store.agentEditor.moveAllVisibleSkills(true)" x-text="`Allow ${$store.agentEditor.filteredSkills(false).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredSkills(false).length" x-text="$store.agentEditor.draft.skillPolicy.mode === 'custom' ? 'No blocked skills — select items on the left, or block new skills until reviewed.' : 'Choose skills to customize standard access.'"></p><template x-for="skill in $store.agentEditor.filteredSkills(false)" :key="skill.path"><div class="policy-item"><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedBlockedSkills" :aria-label="`Select ${skill.name}`"><div><strong x-text="skill.name"></strong><small x-text="skill.origin"></small><p class="policy-item-description" x-show="skill.description" x-text="skill.description"></p><em x-show="skill.available === false">Unavailable — kept in your settings</em></div></div></template></div></section>
295
+ <section class="policy-list" aria-labelledby="blocked-skills-title"><header><div><h4 id="blocked-skills-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredSkills(false).length} ${$store.agentEditor.filteredSkills(false).length === 1 ? 'skill' : 'skills'}`"></span></div><button type="button" class="button policy-bulk" x-show="$store.agentEditor.draft.skillPolicy.mode === 'custom' && $store.agentEditor.filteredSkills(false).length" @click="$store.agentEditor.moveAllVisibleSkills(true)" x-text="`Allow ${$store.agentEditor.filteredSkills(false).length} shown`"></button></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredSkills(false).length" x-text="$store.agentEditor.draft.skillPolicy.mode === 'custom' ? 'No blocked skills — select items on the left, or block new skills until reviewed.' : 'Choose skills to customize standard access.'"></p><template x-for="skill in $store.agentEditor.filteredSkills(false)" :key="skill.path"><div class="policy-item"><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedBlockedSkills" :aria-label="`Select ${skill.name}`"><div><strong x-text="skill.name"></strong><small x-text="skill.origin"></small><p class="policy-item-description" x-show="skill.description" x-text="skill.description"></p><em x-show="skill.available === false">Unavailable — kept in your settings</em></div></div></template></div></section>
296
</div>
297
<fieldset class="future-default"><legend>When new skills are installed later</legend><label><input type="radio" name="skill-future-default" :checked="$store.agentEditor.draft.skillPolicy.default === 'allow'" @change="$store.agentEditor.setPolicyDefault('skill','allow')">Allow automatically</label><label><input type="radio" name="skill-future-default" :checked="$store.agentEditor.draft.skillPolicy.default === 'block'" @change="$store.agentEditor.setPolicyDefault('skill','block')">Block until reviewed</label></fieldset>
298
</fieldset>
@@ -314,11 +314,11 @@
314
<template x-if="$store.agentEditor.plan.warnings?.length"><section><h4>Notices</h4><ul><template x-for="warning in $store.agentEditor.plan.warnings" :key="warning"><li x-text="warning"></li></template></ul></section></template>
315
</div>
316
<div class="review-actions" x-show="$store.agentEditor.pendingMutation"><button type="button" class="button danger" @click="$store.agentEditor.applyPendingMutation()"><x-icon name="delete"></x-icon>Apply removal plan</button><button type="button" class="text-button" @click="$store.agentEditor.pendingMutation = null; $store.agentEditor.previewPlan()">Cancel removal</button></div>
317
- <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.scope_has_overrides && !$store.agentEditor.state.profile.deletable">
317
+ <div class="profile-maintenance" x-show="!$store.agentEditor.pendingMutation && !$store.agentEditor.draft.creating && $store.agentEditor.state.profile.scope_has_overrides && !$store.agentEditor.state.profile.deletable">
318
<h4>Customized by you</h4><p x-text="`Remove only the customizations shown in this editor from ${$store.agentEditor.scopeLabel}. Other files stay in place.`"></p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(false)"><x-icon name="delete_sweep"></x-icon>Remove my changes</button>
319
<details><summary x-text="`Delete all customizations in ${$store.agentEditor.scopeLabel}`"></summary><p>This removes every customization saved in this scope after showing each affected file. Inherited files remain untouched.</p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(true)"><x-icon name="delete_forever"></x-icon>Review files to delete</button></details>
320
</div>
321
- <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.deletable"><h4>Delete custom agent</h4><p>Projects and open chats that use this agent are shown before confirmation, together with its files and settings.</p><button type="button" class="button danger" @click="$store.agentEditor.deleteProfile($store.agentEditor.draft.profileId)">Delete agent</button></div>
321
+ <div class="profile-maintenance" x-show="!$store.agentEditor.pendingMutation && !$store.agentEditor.draft.creating && $store.agentEditor.state.profile.deletable"><h4>Delete custom agent</h4><p>Projects and open chats that use this agent are shown before confirmation, together with its files and settings.</p><button type="button" class="button danger" @click="$store.agentEditor.deleteProfile($store.agentEditor.draft.profileId)">Delete agent</button></div>
322
</section>
323
</div>
324
</div>
@@ -326,7 +326,7 @@
326
</div>
327
</template>
328
329
- <div class="modal-footer agent-editor-footer" data-modal-footer x-show="!$store.agentEditor.loading">
329
+ <div class="modal-footer agent-editor-footer" data-modal-footer x-show="!$store.agentEditor.loading && !($store.agentEditor.view === 'editor' && $store.agentEditor.pendingMutation)">
330
<div class="footer-left">
331
<button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'manage'" @click="window.closeModal?.()">Close</button>
332
<button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'editor'" @click="window.closeModal?.()">Cancel</button>
@@ -352,7 +352,7 @@
352
}
353
.agent-editor h2,.agent-editor h3,.agent-editor h4,.agent-editor p { margin: 0; }
354
.agent-editor button,.agent-editor input,.agent-editor textarea,.agent-editor select { font: inherit; }
355
- .agent-editor input[type="search"],.agent-editor select { min-width:0; min-height:2.25rem; box-sizing:border-box; padding:.5rem .6rem; border:1px solid var(--color-border); border-radius:7px; background:var(--color-input); color:var(--color-text); color-scheme:dark; }
355
+ .agent-editor input[type="search"],.agent-editor select { min-width:0; min-height:2.25rem; box-sizing:border-box; padding:.5rem .6rem; border:1px solid var(--color-border); border-radius:7px; background:var(--color-input); color:var(--color-text); }
356
.agent-editor input[type="checkbox"],.agent-editor input[type="radio"] { appearance:none; flex:0 0 auto; width:1.5rem; height:1.5rem; margin:.05rem 0; border:1px solid var(--color-border); background:var(--color-input); }
357
.agent-editor input[type="checkbox"] { display:grid; place-content:center; border-radius:4px; }
358
.agent-editor input[type="checkbox"]::before { content:""; width:.55rem; height:.3rem; border:solid white; border-width:0 0 2px 2px; opacity:0; transform:translateY(-1px) rotate(-45deg); }
@@ -390,6 +390,7 @@
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; }
392
.agent-field small,.agent-field-heading p,.field-status { color:var(--color-text-secondary); font-size:.79rem; }
393
+ .field-count { color:var(--color-text-secondary); font-size:.79rem; font-weight:400; }
394
.field-status { display:block; margin-top:.15rem; }
395
.field-error { display:block; color:var(--color-text); font-size:.79rem; font-weight:600; line-height:1.35; }
396
.agent-id-feedback { display:flex; flex-wrap:wrap; align-items:center; gap:.35rem .65rem; }
@@ -399,7 +400,7 @@
400
.agent-editor .text-button:hover { text-decoration:underline; }
401
.agent-easy textarea { min-height:11rem; resize:vertical; }
402
.restore-action { display:inline-flex; align-items:center; gap:.25rem; margin-top:.4rem; }
402
- .easy-tool-list { display:flex; flex-direction:column; max-height:18rem; overflow-y:auto; padding:.2rem .35rem .2rem 0; }
403
+ .easy-tool-list { display:flex; flex-direction:column; padding:.2rem .35rem .2rem 0; }
404
.easy-tool-list .policy-item { cursor:pointer; }
405
.easy-skills-hint { margin:.55rem 0 0; color:var(--color-text-secondary); font-size:.79rem; }
406
.agent-advanced { display:grid; grid-template-columns:14rem minmax(0,1fr); gap:1rem; min-height:0; }
@@ -503,7 +504,7 @@
504
.footer-actions { display:flex; gap:.5rem; margin-left:auto; }
505
.agent-editor-footer .btn-cancel,.agent-editor-secondary-action { border:1px solid var(--color-border); background:transparent; color:var(--color-text); }
506
.agent-editor-footer .btn-cancel:hover,.agent-editor-secondary-action:hover { border-color:color-mix(in srgb,var(--agent-editor-action) 50%,var(--color-border)); background:color-mix(in srgb,var(--agent-editor-action) 10%,transparent); color:var(--color-text); }
506
- .agent-editor-secondary-action { display:inline-flex; align-items:center; color:color-mix(in srgb,#fff 82%,var(--agent-editor-action)); }
507
+ .agent-editor-secondary-action { display:inline-flex; align-items:center; }
508
.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)); }
509
.agent-editor .button.danger:hover { background:color-mix(in srgb,var(--agent-editor-danger) 15%,var(--color-panel)); }
510
.agent-manager { display:flex; flex-direction:column; gap:1rem; }
@@ -526,6 +527,7 @@
527
.agent-manager-avatar img { width:100%; height:100%; object-fit:cover; }
528
.agent-manager-copy { min-width:0; }
529
.agent-manager-name { display:flex; flex-wrap:wrap; align-items:center; gap:.4rem; }
530
+ .agent-manager-name strong,.agent-manager-copy p { overflow-wrap:anywhere; }
531
.agent-customized-indicator { display:inline-grid; place-items:center; color:var(--color-text-secondary); }
532
.agent-customized-indicator x-icon { font-size:1rem; }
533
.agent-editor .agent-manager-inline-action { gap:.25rem; margin-top:.15rem; color:var(--color-message-text); opacity:.7; }
plugins/_model_config/AGENTS.md
+2
@@ -31,6 +31,8 @@
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
+- When forced agent-profile catalog loads overlap, only the newest request may
35
+ replace selector state or finish its loading lifecycle.
36
- Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults.
37
- Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`.
38
- 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
@@ -134,6 +134,7 @@
134
135
<template x-for="profile in $store.modelConfig.getAgentProfileList($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label)" :key="profile.key">
136
<div class="agent-profile-row"
137
+ x-show="!$store.modelConfig.agentProfilesLoading"
138
:class="{ 'active': profile.key === $store.chats.selectedContext.agent_profile }">
139
<button type="button" class="model-switcher-item agent-profile-item"
140
:disabled="$store.chats.selectedContext.running || $store.modelConfig.agentProfileSaving"
plugins/_model_config/webui/switcher-mixin.js
+7
-1
@@ -34,12 +34,14 @@ export const switcherState = {
34
agentProfiles: [],
35
agentProfilesLoading: true,
36
agentProfilesLoaded: false,
37
+ agentProfilesLoadSeq: 0,
38
agentProfileSaving: false,
39
};
40
41
export const switcherMethods = {
42
async loadAgentProfiles(force = false) {
43
if (!force && this.agentProfilesLoaded) return this.agentProfiles;
44
+ const requestSeq = ++this.agentProfilesLoadSeq;
45
this.agentProfilesLoading = true;
46
try {
47
const contextId = window.Alpine?.store("chats")?.selected || "";
@@ -47,6 +49,7 @@ export const switcherMethods = {
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 && profile.id !== "_example" && profile.enabled !== false)
55
.map(profile => ({
@@ -57,11 +60,14 @@ export const switcherMethods = {
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;
67
} finally {
64
- this.agentProfilesLoading = false;
68
+ if (requestSeq === this.agentProfilesLoadSeq) {
69
+ this.agentProfilesLoading = false;
70
+ }
71
}
72
return this.agentProfiles;
73
},
tests/test_agent_editor_webui.py
+192
-5
@@ -76,6 +76,9 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
76
assert "model-preset-row" not in modal
77
assert 'class="easy-tool-details"' not in modal
78
assert 'x-for="tool in $store.agentEditor.toolCatalog"' in easy_surface
79
+ assert 'class="field-count"' in easy_surface
80
+ assert "toolCatalog.length === 1 ? 'tool' : 'tools'" in modal
81
+ assert "skillCatalog.length === 1 ? 'skill' : 'skills'" in modal
82
assert ':checked="$store.agentEditor.isToolAllowed(tool)"' in easy_surface
83
assert "$store.agentEditor.setEasyToolAllowed(tool.id, $event.target.checked)" in easy_surface
84
assert "Choose tools in Advanced" not in modal
@@ -155,11 +158,16 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
158
assert modal.count('role="alert"') >= 4
159
assert "Fix ${$store.agentEditor.validationIssues().length}" in modal
160
assert modal.count("$store.agentEditor.validationIssues().length > 0") == 2
161
+ assert "!($store.agentEditor.view === 'editor' && $store.agentEditor.pendingMutation)" in modal
162
+ assert 'x-show="$store.agentEditor.view === \'editor\' && !$store.agentEditor.draft?.creating"' in modal
163
+ assert 'class="btn btn-ok" x-show="$store.agentEditor.view === \'editor\'"' in modal
164
assert "Delete all customizations in" in modal
165
assert 'input[type="checkbox"]' in modal and "appearance:none" in modal
166
assert 'promptDisplayState(prompt)' in modal
167
assert 'promptSourceChain($store.agentEditor.selectedPromptDraft)' in modal
168
assert "Will reset to inherited on save." in modal
169
+ for key in ("title", "description", "context"):
170
+ assert f'x-show="$store.agentEditor.metadataProvenance(\'{key}\')"' in modal
171
assert ':title="prompt.filename"' not in modal
172
assert "promptEditPending($store.agentEditor.selectedPromptDraft)" in modal
173
assert 'aria-label="Discard current edit"' in modal
@@ -178,6 +186,11 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
186
assert "customized: !!profile.has_user_overrides" not in switcher_mixin
187
assert 'name="palette"' in modal and 'name="add_photo_alternate"' in modal
188
assert ".easy-tool-summary" not in modal
189
+ easy_tool_list_style = re.search(
190
+ r"\.easy-tool-list\s*\{([^}]*)\}", modal
191
+ ).group(1)
192
+ assert "max-height" not in easy_tool_list_style
193
+ assert "overflow-y" not in easy_tool_list_style
194
assert "grid-template-columns:minmax(0,1fr) 2.5rem minmax(0,1fr)" in modal
195
assert ".policy-lists .policy-transfer-actions { flex-direction:row; }" in modal
196
assert "easyToolsOpen" not in store_source
@@ -186,10 +199,14 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
199
assert "get easyTools" not in store_source
200
assert "firstSentence" not in store_source
201
assert '.agent-editor [aria-invalid="true"]' not in modal
202
+ assert "color-scheme:dark" not in modal
203
+ assert "#fff 82%" not in modal
204
+ assert ".agent-manager-name strong,.agent-manager-copy p { overflow-wrap:anywhere; }" in modal
205
assert ".field-error { display:block; color:var(--color-text)" in modal
206
assert ".prompt-pane { min-width:0; min-height:0;" in modal
207
assert 'callJsonApi("/plugins/_agent_editor/agent_editor"' in switcher_mixin
208
assert "profile.enabled !== false" in switcher_mixin
209
+ assert 'x-show="!$store.modelConfig.agentProfilesLoading"' in switcher
210
assert "@keydown.ctrl.s.prevent" in modal
211
assert "@media (max-width: 760px)" in modal
212
assert modal.count("data-modal-footer") == 1
@@ -218,12 +235,23 @@ def test_local_slugging_and_fresh_chat_profile_selection_are_deterministic() ->
235
const calls = [];
236
const confirmations = [];
237
let setEnabledHandler = null;
238
+let loadHandler = null;
239
+let confirmResult = false;
240
const createStore = (_name, value) => value;
241
const callJsonApi = async (endpoint, payload) => {
242
calls.push({ endpoint, payload });
243
+ if (payload?.action === "load" && loadHandler) return loadHandler(payload);
244
if (payload?.action === "set_enabled") return setEnabledHandler
245
? setEnabledHandler(payload)
246
: { ok: true, active_profile: "default", active_profile_label: "Default" };
247
+ if (payload?.action === "plan") return {
248
+ ok: true,
249
+ written: [payload.project_name
250
+ ? `usr/projects/${payload.project_name}/.a0proj/agents/${payload.patch.profile_id}/agent.yaml`
251
+ : `usr/agents/${payload.patch.profile_id}/agent.yaml`],
252
+ deleted: [],
253
+ warnings: [],
254
+ };
255
if (payload?.action === "duplicate") return {
256
ok: true,
257
profile_id: "researcher-1",
@@ -235,7 +263,7 @@ const callJsonApi = async (endpoint, payload) => {
263
const fetchApi = async () => ({ ok: true, json: async () => ({}) });
264
const closeModal = async () => {};
265
const openModal = async () => {};
238
-const showConfirmDialog = async options => { confirmations.push(options); return false; };
266
+const showConfirmDialog = async options => { confirmations.push(options); return confirmResult; };
267
const chatsStore = {
268
selected: "old-chat",
269
selectedContext: { project: { name: "demo" }, agent_profile: "researcher" },
@@ -260,6 +288,7 @@ globalThis.CustomEvent = class { constructor(type) { this.type = type; } };
288
globalThis.sessionStorage = { setItem: () => {}, getItem: () => "", removeItem: () => {} };
289
globalThis.localStorage = { setItem: () => {}, getItem: () => "" };
290
globalThis.requestAnimationFrame = callback => callback();
291
+globalThis.confirm = () => true;
292
"""
293
checks = r"""
294
if (slugifyProfileName(" Crème Brûlée__Lab ") !== "creme-brulee-lab") throw new Error("slug mismatch");
@@ -295,12 +324,15 @@ if (store.error) throw new Error("validation leaked into dismissible error banne
324
store.state.profile.id = "new-agent";
325
store.state.profile.origin = "Built-in";
326
store.state.profile.metadata.title = { inherited_source: "agents/new-agent/agent.yaml" };
327
+if (store.metadataProvenance("title") !== "") throw new Error("new profile showed misleading provenance");
328
+store.draft.creating = false;
329
if (store.metadataProvenance("title") !== "Using the default") throw new Error("default provenance mismatch");
330
store.profiles = [{ id: "researcher", title: "Researcher" }];
331
store.state.profile.metadata.title = { inherited_source: "agents/researcher/agent.yaml" };
332
if (store.metadataProvenance("title") !== "Inherited from Researcher") throw new Error("inherited provenance mismatch");
333
store.state.profile.metadata.title.has_override = true;
334
if (store.metadataProvenance("title") !== "Customized by you") throw new Error("custom provenance mismatch");
335
+store.draft.creating = true;
336
store.state.tools.catalog = [
337
{ id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
338
{ id: "local:gone", name: "gone", label: "Gone", origin: "Unavailable", available: false },
@@ -391,18 +423,103 @@ if (store.draft.skillPolicy.mode !== "custom" || store.draft.skillPolicy.default
423
store.draft.title = "Preserved Agent";
424
store.onNameInput();
425
store.instructions.value = "Preserved instructions";
426
+store.draft.description = "Created description";
427
+store.draft.context = "Delegate created work";
428
+const createdMetadata = store.buildPatch().metadata.set;
429
+if (createdMetadata.description !== "Created description" || createdMetadata.context !== "Delegate created work") throw new Error("Advanced create metadata was omitted");
430
+store.mode = "advanced";
431
+store.instructions.value = "";
432
+const callsBeforeInvalidAdvancedCreate = calls.length;
433
+if (!store.fieldIssue("instructions")) throw new Error("Advanced create accepted empty instructions");
434
+await store.save();
435
+if (calls.length !== callsBeforeInvalidAdvancedCreate) throw new Error("Advanced create submitted empty instructions");
436
+store.instructions.value = "Preserved instructions";
437
store.draft.creating = false;
438
+store.mode = "easy";
439
+store.instructions.initialValue = "Preserved instructions";
440
+store.instructions.value = "";
441
+if (!store.fieldIssue("instructions")) throw new Error("existing empty Easy instructions were accepted");
442
+store.instructions.value = "Preserved instructions";
443
+if (store.fieldIssue("instructions")) throw new Error("corrected Easy instructions kept a validation error");
444
+store.mode = "advanced";
445
store.instructions.value = "";
396
-if (store.fieldIssue("instructions")) throw new Error("existing empty instructions were rejected");
446
+if (store.fieldIssue("instructions")) throw new Error("Advanced empty instructions were rejected");
447
store.instructions.value = "Preserved instructions";
448
store.markPromptSet("agent.system.main.specifics.md");
449
if (store.instructions.reset || store.promptEditPending(store.instructions)) throw new Error("Easy instructions did not update its edit checkpoint");
450
store.restoreInstructions();
451
if (!store.instructions.reset || store.instructions.value !== "" || store.promptEditPending(store.instructions)) throw new Error("default instructions were not restored");
402
-store.draft.creating = true;
403
-store.instructions.value = "Preserved instructions";
404
-store.instructions.reset = false;
452
+store.state = {
453
+ profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: { effective: { kind: "color", value: "#111111" } } } },
454
+ prompts: [
455
+ { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "Old instructions", source: "old-source", source_chain: ["Old"] },
456
+ { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Old comm", inherited: "Old comm", source: "old-source", source_chain: ["Old"], state: "Inherited", has_override: false },
457
+ ],
458
+ model_preset: { has_override: false, effective: "Default" },
459
+ model_presets: [],
460
+ tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
461
+ { id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
462
+ { id: "local:old", name: "old", label: "Old", origin: "Old scope", available: true },
463
+ ] },
464
+ skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
465
+ { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research", available: true, tags: [], allowed_tools: [] },
466
+ { name: "Old skill", path: "skills/old/SKILL.md", origin: "Old scope", description: "Old", available: true, tags: [], allowed_tools: [] },
467
+ ] },
468
+};
469
+store.view = "editor";
470
+store.intent = { ...store.intent, view: "create", projectName: "" };
471
+store.makeDraft(true);
472
+store.draft.title = "Scoped Agent";
473
+store.onNameInput();
474
+store.draft.description = "Scoped description";
475
+store.draft.context = "Use for scoped work";
476
+store.instructions.value = "Authored instructions";
477
+store.markPromptSet("agent.system.main.specifics.md");
478
+store.draft.prompts["agent.system.main.communication.md"].value = "Authored communication";
479
+store.acceptPromptEdit("agent.system.main.communication.md");
480
+store.chooseAvatarColor("#ABCDEF");
481
+store.setEasyToolAllowed("local:shell", false);
482
+store.setPolicyDefault("tool", "block");
483
+store.chooseSkills();
484
+store.moveSkills(["Research"], false);
485
+const projectState = {
486
+ profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: { effective: { kind: "color", value: "#222222" } } } },
487
+ prompts: [
488
+ { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "Project instructions", source: "project-source", source_chain: ["Project"] },
489
+ { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source: "project-source", source_chain: ["Project"], state: "Inherited", has_override: false },
490
+ ],
491
+ model_preset: { has_override: false, effective: "Default" },
492
+ model_presets: [],
493
+ tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
494
+ { id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
495
+ { id: "local:new", name: "new", label: "New", origin: "Project", available: true },
496
+ ] },
497
+ skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
498
+ { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research", available: true, tags: [], allowed_tools: [] },
499
+ { name: "New skill", path: "skills/new/SKILL.md", origin: "Project", description: "New", available: true, tags: [], allowed_tools: [] },
500
+ ] },
501
+};
502
+loadHandler = () => ({ ok: true, state: projectState });
503
+store.mode = "advanced";
504
+store.section = "5";
505
+store.projectName = "demo";
506
+store.intent = { ...store.intent, projectName: "" };
507
+calls.length = 0;
508
+await store.onScopeChanged();
509
+if (store.state !== projectState || store.draft.title !== "Scoped Agent" || store.draft.profileId !== "scoped-agent") throw new Error("create scope rebase lost identity");
510
+if (store.draft.description !== "Scoped description" || store.draft.context !== "Use for scoped work") throw new Error("create scope rebase lost authored metadata");
511
+if (store.instructions.value !== "Authored instructions" || store.instructions.source !== "project-source") throw new Error("create scope rebase kept stale prompt provenance");
512
+if (store.draft.avatar?.value !== "#ABCDEF" || store.isToolAllowed(projectState.tools.catalog[0]) || !store.isToolAllowed(projectState.tools.catalog[1]) || store.draft.toolPolicy.default !== "block") throw new Error("create scope rebase lost avatar or explicit tool decision");
513
+if (store.isSkillAllowed(projectState.skills.catalog[0]) || !store.isSkillAllowed(projectState.skills.catalog[1])) throw new Error("create scope rebase lost explicit skill decision");
514
+if (store.draft.prompts["agent.system.main.communication.md"].value !== "Authored communication" || store.draft.prompts["agent.system.main.communication.md"].source !== "project-source" || store.promptEditPending(store.draft.prompts["agent.system.main.communication.md"])) throw new Error("create scope rebase lost an accepted prompt edit or kept stale provenance");
515
+if (calls.at(-1)?.payload?.action !== "plan" || calls.at(-1)?.payload?.project_name !== "demo" || store.planStatus !== "ready" || !store.plan.written[0].startsWith("usr/projects/demo/.a0proj/agents/scoped-agent/")) throw new Error("Review plan was not recomputed after scope change");
516
+loadHandler = null;
517
+store.projectName = "";
518
+store.intent = { ...store.intent, projectName: "" };
519
const communication = store.draft.prompts["agent.system.main.communication.md"];
520
+communication.value = communication.initialValue;
521
+communication.reset = false;
522
+store.acceptPromptEdit(communication.filename);
523
if (store.filteredPromptFiles("2.4")[0] !== communication) throw new Error("grouped prompt filter mismatch");
524
if (store.promptEditPending(communication) || store.promptDisplayState(communication) !== "Default") throw new Error("default prompt checkpoint mismatch");
525
communication.value += "\nNew rule";
@@ -442,15 +559,33 @@ calls.length = 0;
559
store.projectName = "demo";
560
await store.openFreshChat("researcher", false);
561
if (calls[1].endpoint !== "/projects" || calls[1].payload.action !== "activate" || calls[1].payload.name !== "demo") throw new Error("project test chat did not activate selected scope");
562
+store.draft.title = `${store.draft.title} dirty`;
563
+calls.length = 0;
564
+if (await store.planRemoval(true)) throw new Error("removal plan accepted unsaved edits");
565
+if (calls.length || !store.error.includes("Save or discard")) throw new Error("dirty removal plan was not blocked locally");
566
+store.initialDraft = clone(store.draft);
567
+store.error = "";
568
await store.planRemoval(true);
569
if (!store.pendingMutation?.destructive || store.section !== "5" || store.planStatus !== "ready") throw new Error("removal plan was replaced");
570
if (calls.at(-1).payload.action !== "plan_remove_changes") throw new Error("removal plan request missing");
571
if (calls.at(-1).payload.project_name !== "demo") throw new Error("removal request lost selected scope");
572
+const callsBeforePendingSave = calls.length;
573
+if (await store.save() || calls.length !== callsBeforePendingSave) throw new Error("ordinary save ran over a pending removal plan");
574
+store.draft.title += " changed after planning";
575
+if (await store.applyPendingMutation() !== false || confirmations.length || calls.length !== callsBeforePendingSave || !store.error.includes("before applying")) throw new Error("removal plan discarded edits made after planning");
576
+store.draft.title = store.initialDraft.title;
577
+store.error = "";
578
store.plan = { written: ["usr/agents/researcher/agent.yaml"], deleted: ["usr/agents/researcher/prompts/old.md"], warnings: [] };
579
+confirmResult = true;
580
+loadHandler = () => ({ ok: true, state: store.state });
581
await store.applyPendingMutation();
582
if (confirmations.length !== 1 || confirmations[0].type !== "danger") throw new Error("danger confirmation missing");
583
if (!confirmations[0].message.includes("agent.yaml") || !confirmations[0].message.includes("old.md")) throw new Error("planned paths missing from confirmation");
584
if (confirmations[0].title !== "Delete all customizations for this profile?") throw new Error("cleanup confirmation title mismatch");
585
+const removalCall = calls.find(item => item.payload?.action === "remove_changes");
586
+if (!removalCall || removalCall.payload.confirm !== true || removalCall.payload.destructive !== true) throw new Error("destructive removal omitted explicit confirmation");
587
+loadHandler = null;
588
+confirmResult = false;
589
confirmations.length = 0;
590
calls.length = 0;
591
store.projectName = "";
@@ -458,6 +593,58 @@ await store.deleteProfile("custom-agent");
593
if (calls.length) throw new Error("cancelled deletion made an API request");
594
if (confirmations.length !== 1 || confirmations[0].title !== "Delete custom-agent?") throw new Error("delete confirmation mismatch");
595
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");
596
+confirmResult = true;
597
+store.view = "editor";
598
+store.mode = "advanced";
599
+store.draft = { title: "dirty", avatar: null, avatarToken: "", metadataResets: [] };
600
+store.initialDraft = { title: "clean", avatar: null, avatarToken: "", metadataResets: [] };
601
+store.promptEditBaselines = { stale: { value: "stale", reset: false } };
602
+await store.deleteProfile("custom-agent");
603
+if (store.view !== "manage" || store.mode !== "easy" || store.draft !== null || store.initialDraft !== null || Object.keys(store.promptEditBaselines).length) throw new Error("delete did not enter a clean Manage state");
604
+store.view = "editor";
605
+store.mode = "advanced";
606
+store.draft = { title: "dirty" };
607
+store.initialDraft = { title: "clean" };
608
+store.promptEditBaselines = { stale: { value: "stale", reset: false } };
609
+store.error = "stale";
610
+store.showManager();
611
+if (store.view !== "manage" || store.mode !== "easy" || store.draft !== null || store.initialDraft !== null || store.error || Object.keys(store.promptEditBaselines).length) throw new Error("Back did not discard editor state before Manage");
612
+"""
613
+ module_source = harness + "\n" + source + "\n" + checks
614
+ module_url = "data:text/javascript;base64," + base64.b64encode(
615
+ module_source.encode("utf-8")
616
+ ).decode("ascii")
617
+ subprocess.run(
618
+ ["node", "--input-type=module", "-e", f"await import('{module_url}')"],
619
+ check=True,
620
+ text=True,
621
+ )
622
+
623
+
624
+@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
625
+def test_latest_agent_profile_load_owns_switcher_state() -> None:
626
+ source = SWITCHER_MIXIN.read_text(encoding="utf-8")
627
+ source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
628
+ harness = r"""
629
+const pending = [];
630
+const callJsonApi = async () => await new Promise(resolve => pending.push(resolve));
631
+const fetchApi = async () => ({ ok: true, json: async () => ({}) });
632
+globalThis.window = { Alpine: { store: () => ({ selected: "ctx" }) } };
633
+"""
634
+ checks = r"""
635
+const store = { ...switcherState, ...switcherMethods };
636
+const older = store.loadAgentProfiles(true);
637
+const newer = store.loadAgentProfiles(true);
638
+if (pending.length !== 2 || !store.agentProfilesLoading) throw new Error("overlapping profile loads did not start");
639
+pending[1]({ profiles: [{ id: "new", title: "New", enabled: true }] });
640
+await newer;
641
+if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("newest profile load did not settle");
642
+pending[0]({ profiles: [{ id: "old", title: "Old", enabled: true }] });
643
+await older;
644
+if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("stale profile load replaced newer state");
645
+const requestCount = pending.length;
646
+await store.loadAgentProfiles();
647
+if (pending.length !== requestCount) throw new Error("cached profile catalog unexpectedly reloaded");
648
"""
649
module_source = harness + "\n" + source + "\n" + checks
650
module_url = "data:text/javascript;base64," + base64.b64encode(