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(