Unify Agent Editor capability and prompt controls

Give Tools, MCPs, and Skills explicit default switches with segmented per-item policy controls while preserving sparse overrides. Simplify Advanced prompt editing around the full-height ACE surface and align the editor contracts and regression coverage.
This commit is contained in:
Alessandro 2026-08-11 14:22:37 +02:00
parent 9dab4f2460
commit 13ecab24ee
12 changed files with 621 additions and 397 deletions

View file

@ -28,8 +28,10 @@ def normalize_policy(config: Any) -> dict[str, Any]:
raw = dict(config) if isinstance(config, dict) else {}
mode = str(raw.get("mode") or "inherit").strip().lower()
default = str(raw.get("default") or "allow").strip().lower()
mcp_default = str(raw.get("mcp_default") or "allow").strip().lower()
raw["mode"] = "custom" if mode == "custom" else "inherit"
raw["default"] = "block" if default == "block" else "allow"
raw["mcp_default"] = "block" if mcp_default == "block" else "allow"
raw["allowed"] = _normalize_ids(raw.get("allowed"))
raw["blocked"] = _normalize_ids(raw.get("blocked"))
return raw
@ -49,7 +51,8 @@ def get_policy(agent: Any) -> dict[str, Any]:
):
config = files.read_file_json(asset["path"])
if not isinstance(config, dict) or not any(
key in config for key in ("mode", "default", "allowed", "blocked")
key in config
for key in ("mode", "default", "mcp_default", "allowed", "blocked")
):
continue
policy = normalize_policy(config)
@ -171,7 +174,8 @@ def resolve_tool(
if tool_id in policy["allowed"]:
return ToolPolicyDecision(True, tool_id, "scoped-policy", "custom")
is_allowed = policy["default"] == "allow"
default_key = "mcp_default" if tool_id.startswith("mcp:") else "default"
is_allowed = policy[default_key] == "allow"
return ToolPolicyDecision(
is_allowed,
tool_id,

View file

@ -31,8 +31,9 @@
active project, user profile, bundled/plugin profile, then default.
`get_policy` selects the first custom policy; unknown-only and
explicit-inherit files remain on disk but defer to the next lower layer.
- Missing policy inherits standard access; custom policy always records whether
future tools default to allowed or blocked.
- Missing policy inherits standard access. A custom policy records independent
defaults for local/plugin tools and canonical MCP tools; explicit allowed or
blocked IDs take precedence over either default.
- The `response` capability is a framework-required invariant: profile policy
cannot disable it, and the editor does not list it as a configurable tool.
- `vision_load` remains owned by the active chat model's vision configuration;

View file

@ -44,18 +44,25 @@
Removing or deleting in project scope never mutates those inherited layers;
only agents created in the selected scope are deletable.
- Bundled `agents/` files are read-only.
- Advanced prompt text is directly editable; per-file close/check actions
discard or accept the current edit checkpoint, while the editor's global save
remains the only persistence boundary.
- Advanced prompt text is edited with a full-height bundled ACE editor in
Markdown mode. The selected file's customization path sits below its name;
per-file close/check actions discard or accept the current edit checkpoint,
while the editor's global save remains the only persistence boundary.
- New profiles require a display name and non-empty agent instructions in both
Easy and Advanced; existing Advanced prompt edits retain per-file semantics.
- The configurable tool catalog is visible in both modes; Easy provides direct
allow/block checkboxes and points to Advanced for skill access. Skills remain
Advanced-only. Advanced keeps both complete selectors visible but disabled
for inherited access and interactive for custom access. Framework-required
tools remain absent from the tool catalog.
- Model selection reuses `_model_config`'s compact preset dropdown and preset
editor; Agent Editor persists only the scoped preset reference.
- Easy and Advanced share the same segmented capability controls: Default
removes the item from `allowed` and `blocked`, On stores it in `allowed`, and
Off stores it in `blocked`. Tools, canonical MCP entries, and Skills expose
independent default switches; explicit choices remain pinned when a default
changes, and opening then undoing an inherited policy produces no write. Easy
places each initially closed native accordion directly below its default
switch. Advanced gives Tools, MCPs, and Skills separate sections while keeping
unavailable retained IDs reviewable. Framework-required tools remain absent.
- Model selection in both modes reuses `_model_config`'s compact preset dropdown
and preset editor; Agent Editor persists only the scoped preset reference.
- The exact `default` profile is an internal baseline and is omitted only from
selectable and editable UI rows. Runtime discovery remains unchanged, and an
existing chat using it may still report it as current status.
- Manage agents reuses the plugin-settings project vocabulary: Global or one
existing project. The active chat profile appears once above the list; each
row exposes scoped availability, duplication, restore for inherited profiles,

View file

@ -8,6 +8,12 @@ The editor never invokes a model. Tool and skill controls are backed by the
central runtime policy owners, and every save is previewed as exact file writes
and deletions before the same validated plan is applied.
Easy and Advanced use the same segmented capability policy: On pins an item
allowed, Off pins it blocked, and Default follows the profile's Tools, MCPs, or
Skills default switch. Easy places each chooser below its default switch;
Advanced gives Tools, MCPs, and Skills separate searchable sections and adds
retained unavailable entries plus ACE-based prompt editing.
Global agents and customizations live under `usr/agents/<profile-id>` and apply
across projects. Project-scoped agents and customizations live under
`usr/projects/<project>/.a0proj/agents/<profile-id>`, inherit the Global layer,
@ -17,3 +23,5 @@ Manage agents can duplicate the effective profile into the selected scope and
toggle whether each profile is available there. Project availability reuses
`.a0proj/agents.json`; Global availability is a sparse profile override.
The selected scope must always keep at least one profile available.
The bundled `default` profile remains the internal inheritance baseline and is
not offered as a selectable or editable profile.

View file

@ -32,7 +32,7 @@ RESERVED_PROFILE_IDS = {"_example"}
NON_PROMPT_MARKDOWN = {"AGENTS.md"}
USER_AGENTS_ROOT = Path(files.get_abs_path(subagents.USER_AGENTS_DIR))
STAGED_AVATAR_ROOT = Path(files.get_abs_path("tmp", "agent-editor"))
_POLICY_KEYS = ("mode", "default", "allowed", "blocked")
_TOOL_POLICY_KEYS = ("mode", "default", "mcp_default", "allowed", "blocked")
_MUTATION_LOCK = threading.RLock()
@ -319,7 +319,7 @@ def build_editor_state(
"tools": {
"policy": tool_policy.normalize_policy(tool_scope),
"effective_policy": tool_policy.get_policy(agent),
"has_override": any(key in tool_scope for key in _POLICY_KEYS),
"has_override": any(key in tool_scope for key in _TOOL_POLICY_KEYS),
"catalog": tool_catalog,
},
"skills": {
@ -1020,11 +1020,17 @@ def _plan_tool_policy(plan: ChangePlan, value: Any) -> None:
data = _read_mapping_strict(path, "tool policy configuration")
if mode == "inherit":
for key in _POLICY_KEYS:
for key in _TOOL_POLICY_KEYS:
data.pop(key, None)
else:
if mode == "off":
policy = {"mode": "custom", "default": "block", "allowed": [], "blocked": []}
policy = {
"mode": "custom",
"default": "block",
"mcp_default": "block",
"allowed": [],
"blocked": [],
}
elif mode == "custom":
policy = tool_policy.normalize_policy(section)
allowed = set(policy["allowed"])
@ -1036,7 +1042,7 @@ def _plan_tool_policy(plan: ChangePlan, value: Any) -> None:
raise ValueError(f'Invalid canonical tool ID "{tool_id}".')
else:
raise ValueError("Tool policy mode must be inherit, off, or custom.")
data.update({key: policy[key] for key in _POLICY_KEYS})
data.update({key: policy[key] for key in _TOOL_POLICY_KEYS})
_plan_json_mapping(plan, path, data)

View file

@ -29,7 +29,7 @@ export function slugifyProfileName(value) {
.replace(/[-_]+$/g, "");
}
function policyFromState(value, hasOverride) {
function policyFromState(value, hasOverride, includeMcpDefault = false) {
const policy = value && typeof value === "object" ? value : {};
const normalized = {
mode: policy.mode === "custom" ? "custom" : "inherit",
@ -37,6 +37,9 @@ function policyFromState(value, hasOverride) {
allowed: unique(policy.allowed),
blocked: unique(policy.blocked),
};
if (includeMcpDefault) {
normalized.mcp_default = policy.mcp_default === "block" ? "block" : "allow";
}
if (!hasOverride || normalized.mode !== "custom") normalized.mode = "inherit";
return normalized;
}
@ -45,27 +48,42 @@ function policyAllows(policy, id) {
if (!policy || policy.mode !== "custom") return true;
if (policy.blocked.includes(id)) return false;
if (policy.allowed.includes(id)) return true;
return policy.default === "allow";
const fallback = String(id || "").startsWith("mcp:") ? policy.mcp_default : policy.default;
return fallback === "allow";
}
function policyBehavior(policy) {
function policyItemState(policy, id) {
if (!policy || policy.mode !== "custom") return "default";
if (policy?.blocked?.includes(id)) return "block";
if (policy?.allowed?.includes(id)) return "allow";
return "default";
}
function policyBehavior(policy, includeMcpDefault = false) {
const value = policy || {};
if (value.mode !== "custom") {
return { default: "allow", allowed: [], blocked: [] };
return {
default: "allow",
...(includeMcpDefault ? { mcp_default: "allow" } : {}),
allowed: [],
blocked: [],
};
}
return {
default: value.default === "block" ? "block" : "allow",
...(includeMcpDefault
? { mcp_default: value.mcp_default === "block" ? "block" : "allow" }
: {}),
allowed: unique(value.allowed).sort(),
blocked: unique(value.blocked).sort(),
};
}
function movePolicyItem(policy, id, allow) {
function setPolicyItemState(policy, id, state) {
policy.allowed = policy.allowed.filter((item) => item !== id);
policy.blocked = policy.blocked.filter((item) => item !== id);
const exceptions = allow ? "allowed" : "blocked";
const matchesDefault = allow === (policy.default === "allow");
if (!matchesDefault) policy[exceptions].push(id);
if (state === "allow") policy.allowed.push(id);
if (state === "block") policy.blocked.push(id);
}
function escapeHtml(value) {
@ -93,17 +111,14 @@ const model = {
initialDraft: null,
selectedPrompt: SPECIFICS,
promptFileSearch: "",
promptTextSearch: "",
comparePrompt: "",
toolSearch: "",
toolCategory: "all",
toolOrigin: "all",
selectedAllowedTools: [],
selectedBlockedTools: [],
skillSearch: "",
skillOrigin: "all",
selectedAllowedSkills: [],
selectedBlockedSkills: [],
promptEditor: null,
promptEditorChangeHandler: null,
settingPromptEditorValue: false,
aceUnavailable: false,
promptEditBaselines: {},
plan: { written: [], deleted: [], warnings: [] },
planLoading: false,
@ -136,10 +151,15 @@ const model = {
: String(options.projectName || ""),
};
this.suppressClosePrompt = false;
return await openModal(MODAL, () => this.beforeClose());
try {
return await openModal(MODAL, () => this.beforeClose());
} finally {
this.destroyPromptEditor();
}
},
async mount(root) {
this.destroyPromptEditor();
this.revokePreview();
this.root = root;
this.draft = null;
@ -248,6 +268,10 @@ const model = {
return this.profiles.find((profile) => this.isProfileActive(profile.id)) || null;
},
get visibleProfiles() {
return this.profiles.filter((profile) => profile.id !== "default");
},
async setProfileEnabled(profile, enabled) {
if (!profile?.id || this.profileAvailabilitySaving) return;
const previous = !!profile.enabled;
@ -343,12 +367,11 @@ const model = {
this.projectName = previous;
return;
}
const reviewActive = this.mode === "advanced" && this.section === "5";
const reviewActive = this.mode === "advanced" && this.section === "6";
const creatingDraft = this.draft?.creating ? this.draft : null;
const creatingInitial = creatingDraft ? this.initialDraft : null;
const creatingState = creatingDraft ? this.state : null;
const selectedPrompt = this.selectedPrompt;
const comparePrompt = this.comparePrompt;
const promptChanges = creatingDraft
? Object.values(creatingDraft.prompts).flatMap((prompt) => {
const initial = creatingInitial?.prompts?.[prompt.filename];
@ -376,13 +399,14 @@ const model = {
key,
idKey,
default: policy.default,
mcpDefault: policy.mcp_default,
choices: (oldState.catalog || [])
.map((item) => [
item[idKey],
policyAllows(policy, item[idKey]),
policyAllows(oldState.effective_policy, item[idKey]),
policyItemState(policy, item[idKey]),
policyItemState(oldState.effective_policy, item[idKey]),
])
.filter(([, allowed, inherited]) => allowed !== inherited),
.filter(([, state, inherited]) => state !== inherited),
});
}
}
@ -426,7 +450,6 @@ const model = {
if (change.baseline) this.promptEditBaselines[change.filename] = change.baseline;
}
if (this.draft.prompts[selectedPrompt]) this.selectedPrompt = selectedPrompt;
this.comparePrompt = comparePrompt;
if (avatarChanged) {
this.draft.avatar = clone(creatingDraft.avatar);
this.draft.avatarToken = creatingDraft.avatarToken;
@ -438,12 +461,16 @@ const model = {
if (modelPresetChanged) this.draft.modelPreset = creatingDraft.modelPreset;
for (const change of policyChanges) {
this.customizePolicy(change.kind);
this.setPolicyDefault(change.kind, change.default);
this.draft[change.key].default = change.default;
if (change.kind === "tool") {
this.draft[change.key].mcp_default = change.mcpDefault;
}
const catalog = this.state[change.kind === "tool" ? "tools" : "skills"].catalog || [];
const nextIds = new Set(catalog.map((item) => item[change.idKey]));
for (const [id, allowed] of change.choices) {
if (nextIds.has(id)) movePolicyItem(this.draft[change.key], id, allowed);
for (const [id, state] of change.choices) {
if (nextIds.has(id)) setPolicyItemState(this.draft[change.key], id, state);
}
this.collapsePolicy(change.kind);
}
}
if (reviewActive) await this.previewPlan();
@ -524,23 +551,19 @@ const model = {
modelPreset: this.state.model_preset.has_override
? String(this.state.model_preset.override || "")
: "",
toolPolicy: policyFromState(this.state.tools.policy, this.state.tools.has_override),
toolPolicy: policyFromState(this.state.tools.policy, this.state.tools.has_override, true),
skillPolicy: policyFromState(this.state.skills.policy, this.state.skills.has_override),
};
this.initialDraft = clone(this.draft);
this.selectedPrompt = SPECIFICS;
this.comparePrompt = "";
this.planStatus = "idle";
this.selectedAllowedTools = [];
this.selectedBlockedTools = [];
this.selectedAllowedSkills = [];
this.selectedBlockedSkills = [];
this.promptEditBaselines = Object.fromEntries(
Object.values(prompts).map((prompt) => [prompt.filename, {
value: prompt.value,
reset: prompt.reset,
}]),
);
this.schedulePromptEditor();
},
get dirty() {
@ -564,6 +587,14 @@ const model = {
return (this.state?.tools?.catalog || []).filter((item) => item.available !== false);
},
get standardToolCatalog() {
return this.toolCatalog.filter((item) => !String(item.id || "").startsWith("mcp:"));
},
get mcpCatalog() {
return this.toolCatalog.filter((item) => String(item.id || "").startsWith("mcp:"));
},
get toolOrigins() {
return unique((this.state?.tools?.catalog || []).map((item) => item.origin)).sort();
},
@ -599,8 +630,8 @@ const model = {
);
}
if (String(section) === "2") return Object.values(this.draft.prompts).some((prompt) => this.promptDirty(prompt));
if (String(section) === "3") return !same(this.draft.toolPolicy, this.initialDraft.toolPolicy);
if (String(section) === "4") return !same(this.draft.skillPolicy, this.initialDraft.skillPolicy);
if (["3", "4"].includes(String(section))) return !same(this.draft.toolPolicy, this.initialDraft.toolPolicy);
if (String(section) === "5") return !same(this.draft.skillPolicy, this.initialDraft.skillPolicy);
return this.dirty;
},
@ -622,6 +653,7 @@ const model = {
},
enterManager() {
this.destroyPromptEditor();
this.revokePreview();
this.draft = null;
this.initialDraft = null;
@ -638,12 +670,13 @@ const model = {
setMode(mode, section = "", preview = true) {
this.mode = mode === "advanced" ? "advanced" : "easy";
if (section) this.setSection(section, preview);
else if (preview && this.mode === "advanced" && this.section === "5") this.previewPlan();
else if (preview && this.mode === "advanced" && this.section === "6") this.previewPlan();
this.syncSurface();
if (this.mode === "advanced") {
requestAnimationFrame(() => {
this.root?.querySelector(`[data-agent-editor-section="${this.section}"]`)?.focus();
});
this.schedulePromptEditor();
}
},
@ -652,7 +685,8 @@ const model = {
try {
localStorage.setItem(LAST_SECTION_KEY, this.section);
} catch {}
if (preview && this.section === "5") this.previewPlan();
if (preview && this.section === "6") this.previewPlan();
if (this.section === "2") this.schedulePromptEditor();
},
savedSection() {
@ -791,6 +825,7 @@ const model = {
prompt.value = String(prompt.inherited || "");
prompt.reset = true;
this.acceptPromptEdit(prompt.filename);
this.syncPromptEditor();
},
markPromptSet(filename) {
@ -830,6 +865,7 @@ const model = {
if (!prompt || !baseline) return;
prompt.value = baseline.value;
prompt.reset = baseline.reset;
this.syncPromptEditor();
},
resetPrompt(filename) {
@ -838,6 +874,7 @@ const model = {
prompt.value = prompt.inherited;
prompt.reset = true;
this.acceptPromptEdit(filename);
this.syncPromptEditor();
},
promptDisplayState(prompt) {
@ -847,25 +884,10 @@ const model = {
return this.projectName ? "Inherited" : "Default";
},
promptSourceChain(prompt) {
if (!prompt?.reset && (prompt?.has_override || this.promptDirty(prompt))) return "Customized by you";
if (this.projectName) return "Inherited from Global";
const source = [...(prompt?.source_chain || [])]
.filter((item) => item !== "Your override")
.at(-1);
const current = String(
this.state?.profile?.metadata?.title?.effective || this.state?.profile?.id || "",
);
return !source || source.toLowerCase() === current.toLowerCase()
? "Default"
: `Inherited from ${source}`;
},
selectPrompt(filename) {
if (!this.draft?.prompts?.[filename]) return;
this.selectedPrompt = filename;
this.promptTextSearch = "";
this.comparePrompt = "";
this.schedulePromptEditor();
},
filteredPromptFiles(group = "") {
@ -880,26 +902,68 @@ const model = {
return Boolean(prompt?.reset || prompt?.value !== prompt?.initialValue);
},
promptMatchCount() {
const query = this.promptTextSearch;
const text = this.selectedPromptDraft?.value || "";
if (!query) return 0;
return text.toLowerCase().split(query.toLowerCase()).length - 1;
schedulePromptEditor() {
if (this.mode !== "advanced" || this.section !== "2") return;
requestAnimationFrame(() => requestAnimationFrame(() => this.initPromptEditor()));
},
findInPrompt(direction = 1) {
const textarea = this.root?.querySelector("#agent-editor-prompt-text");
const query = this.promptTextSearch;
const text = this.selectedPromptDraft?.value || "";
if (!textarea || !query) return;
const lower = text.toLowerCase();
const needle = query.toLowerCase();
const start = direction > 0 ? textarea.selectionEnd : Math.max(0, textarea.selectionStart - 1);
let index = direction > 0 ? lower.indexOf(needle, start) : lower.lastIndexOf(needle, start);
if (index < 0) index = direction > 0 ? lower.indexOf(needle) : lower.lastIndexOf(needle);
if (index < 0) return;
textarea.focus();
textarea.setSelectionRange(index, index + query.length);
initPromptEditor() {
const container = this.root?.querySelector("#agent-editor-prompt-ace");
if (!container) return;
if (this.promptEditor && !this.root?.contains?.(this.promptEditor.container)) {
this.destroyPromptEditor();
}
if (this.promptEditor) {
this.syncPromptEditor();
this.promptEditor.resize?.(true);
return;
}
if (!globalThis.ace?.edit) {
this.aceUnavailable = true;
return;
}
const editor = globalThis.ace.edit(container);
const darkMode = globalThis.localStorage?.getItem("darkMode");
editor.setTheme(darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github");
editor.session.setMode("ace/mode/markdown");
editor.session.setUseWrapMode(true);
editor.setOptions({ showPrintMargin: false, useWorker: false });
editor.setValue(this.selectedPromptDraft?.value || "", -1);
this.promptEditorChangeHandler = () => {
if (this.settingPromptEditorValue || !this.selectedPromptDraft) return;
this.selectedPromptDraft.value = editor.getValue();
this.onPromptInput(this.selectedPrompt);
};
editor.session.on("change", this.promptEditorChangeHandler);
editor.textInput?.getElement?.()?.setAttribute("aria-label", "Prompt Markdown");
this.promptEditor = editor;
this.aceUnavailable = false;
},
syncPromptEditor() {
if (!this.promptEditor) {
this.schedulePromptEditor();
return;
}
const value = String(this.selectedPromptDraft?.value || "");
if (this.promptEditor.getValue() !== value) {
this.settingPromptEditorValue = true;
this.promptEditor.setValue(value, -1);
this.settingPromptEditorValue = false;
}
this.promptEditor.resize?.(true);
},
destroyPromptEditor() {
if (this.promptEditor?.session && this.promptEditorChangeHandler) {
this.promptEditor.session.off?.("change", this.promptEditorChangeHandler);
}
const container = this.promptEditor?.container;
this.promptEditor?.destroy?.();
if (container) container.textContent = "";
this.promptEditor = null;
this.promptEditorChangeHandler = null;
this.settingPromptEditorValue = false;
},
promptCustomizationPath() {
@ -914,55 +978,61 @@ const model = {
globalThis.justToast?.("Path copied", "success", 1200, "agent-editor-copy");
},
useStandardTools() {
this.draft.toolPolicy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
this.selectedAllowedTools = [];
this.selectedBlockedTools = [];
},
customizePolicy(kind) {
const key = kind === "tool" ? "toolPolicy" : "skillPolicy";
const isSkill = kind === "skill";
const key = isSkill ? "skillPolicy" : "toolPolicy";
if (this.draft[key].mode === "custom") return;
const state = kind === "tool" ? this.state.tools : this.state.skills;
this.draft[key] = { mode: "custom", ...policyBehavior(state.effective_policy) };
const state = isSkill ? this.state.skills : this.state.tools;
this.draft[key] = {
mode: "custom",
...policyBehavior(state.effective_policy, !isSkill),
};
},
chooseTools() {
this.customizePolicy("tool");
this.setMode("advanced", "3");
activePolicy(kind) {
const isSkill = kind === "skill";
const key = isSkill ? "skillPolicy" : "toolPolicy";
const state = isSkill ? this.state?.skills : this.state?.tools;
return this.draft?.[key]?.mode === "custom" ? this.draft[key] : state?.effective_policy;
},
setEasyToolAllowed(id, allow) {
this.customizePolicy("tool");
this.moveTools([id], allow);
const policy = this.draft.toolPolicy;
if (this.initialDraft?.toolPolicy.mode !== "custom"
&& same(policyBehavior(policy), policyBehavior(this.state.tools.effective_policy))) {
this.useStandardTools();
policyItemState(kind, id) {
return policyItemState(this.activePolicy(kind), id);
},
setPolicyItem(kind, id, state) {
this.customizePolicy(kind);
const key = kind === "skill" ? "skillPolicy" : "toolPolicy";
setPolicyItemState(this.draft[key], id, state);
this.collapsePolicy(kind);
},
collapsePolicy(kind) {
const isSkill = kind === "skill";
const key = isSkill ? "skillPolicy" : "toolPolicy";
const state = isSkill ? this.state.skills : this.state.tools;
if (this.initialDraft?.[key]?.mode !== "custom"
&& same(
policyBehavior(this.draft[key], !isSkill),
policyBehavior(state.effective_policy, !isSkill),
)) {
this.draft[key] = clone(this.initialDraft[key]);
}
},
useStandardSkills() {
this.draft.skillPolicy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
this.selectedAllowedSkills = [];
this.selectedBlockedSkills = [];
},
chooseSkills() {
this.customizePolicy("skill");
},
setPolicyDefault(kind, nextDefault) {
const policy = kind === "tool" ? this.draft.toolPolicy : this.draft.skillPolicy;
const catalog = kind === "tool" ? this.state.tools.catalog : this.state.skills.catalog;
const ids = catalog.map((item) =>
kind === "tool" ? item.id : item.name,
);
const current = new Map(ids.map((id) => [id, policyAllows(policy, id)]));
policy.default = nextDefault === "block" ? "block" : "allow";
policy.allowed = [];
policy.blocked = [];
for (const [id, allowed] of current) movePolicyItem(policy, id, allowed);
this.customizePolicy(kind);
const key = kind === "skill" ? "skillPolicy" : "toolPolicy";
const policy = this.draft[key];
const field = kind === "mcp" ? "mcp_default" : "default";
policy[field] = nextDefault === "block" ? "block" : "allow";
this.collapsePolicy(kind);
},
policyDefault(kind) {
const policy = this.activePolicy(kind);
const field = kind === "mcp" ? "mcp_default" : "default";
return policy?.mode === "custom" && policy[field] === "block" ? "block" : "allow";
},
isToolAllowed(item) {
@ -979,73 +1049,26 @@ const model = {
return policyAllows(policy, item.name);
},
filteredTools(allowed) {
filteredTools(group = "tool") {
const query = this.toolSearch.trim().toLowerCase();
return (this.state?.tools?.catalog || []).filter((item) => {
if (this.draft.toolPolicy.mode !== "custom" && item.available === false) return false;
if (this.isToolAllowed(item) !== allowed) return false;
const category = item.id.split(":", 1)[0];
if (this.toolCategory !== "all" && category !== this.toolCategory) return false;
const isMcp = String(item.id || "").startsWith("mcp:");
if ((group === "mcp") !== isMcp) return false;
if (this.toolOrigin !== "all" && item.origin !== this.toolOrigin) return false;
return !query || [item.label, item.name, item.id, item.description, item.origin]
.join(" ").toLowerCase().includes(query);
});
},
moveTools(ids, allow) {
for (const id of unique(ids)) movePolicyItem(this.draft.toolPolicy, id, allow);
this.selectedAllowedTools = [];
this.selectedBlockedTools = [];
},
moveAllVisibleTools(allow) {
return this.confirmBulkMove(
"tool",
this.filteredTools(!allow).map((item) => item.id),
allow,
);
},
filteredSkills(allowed) {
filteredSkills() {
const query = this.skillSearch.trim().toLowerCase();
return (this.state?.skills?.catalog || []).filter((item) => {
if (this.draft.skillPolicy.mode !== "custom" && item.available === false) return false;
if (this.isSkillAllowed(item) !== allowed) return false;
if (this.skillOrigin !== "all" && item.origin !== this.skillOrigin) return false;
return !query || [item.name, item.description, item.origin, ...(item.tags || [])]
.join(" ").toLowerCase().includes(query);
});
},
moveSkills(ids, allow) {
for (const id of unique(ids)) movePolicyItem(this.draft.skillPolicy, id, allow);
this.selectedAllowedSkills = [];
this.selectedBlockedSkills = [];
},
moveAllVisibleSkills(allow) {
return this.confirmBulkMove(
"skill",
this.filteredSkills(!allow).map((item) => item.name),
allow,
);
},
async confirmBulkMove(kind, ids, allow) {
if (!ids.length) return;
const action = allow ? "Allow" : "Block";
const plural = `${kind}${ids.length === 1 ? "" : "s"}`;
const confirmed = await showConfirmDialog({
title: `${action} ${ids.length} shown ${plural}?`,
message: `<p>This changes every ${kind} currently shown by your filters.</p>`,
confirmText: `${action} shown ${plural}`,
type: "warning",
});
if (!confirmed) return;
if (kind === "tool") this.moveTools(ids, allow);
else this.moveSkills(ids, allow);
},
skillWarnings(skill) {
const warnings = [];
for (const toolName of skill.allowed_tools || []) {
@ -1090,7 +1113,10 @@ const model = {
const message = this.draft.creating
? "Instructions are required for a new agent."
: `Instructions cant be empty. Use ${fallback} instructions instead.`;
issues.push({ key: "instructions", section: "2", field: "agent-editor-prompt-text", label: "Instructions", message });
const field = this.mode === "advanced"
? "agent-editor-prompt-ace"
: "agent-editor-instructions";
issues.push({ key: "instructions", section: "2", field, label: "Instructions", message });
}
if (this.avatarUploading) {
issues.push({ key: "avatar", section: "1", field: "agent-editor-advanced-name", label: "Avatar", message: "Wait for the avatar upload to finish." });
@ -1296,7 +1322,7 @@ const model = {
this.plan = data;
this.planStatus = "ready";
this.pendingMutation = { destructive };
this.setMode("advanced", "5", false);
this.setMode("advanced", "6", false);
} catch (error) {
this.error = error.message || String(error);
} finally {

View file

@ -50,13 +50,13 @@
</span>
<strong x-text="$store.agentEditor.activeProfile().title || $store.agentEditor.activeProfile().id"></strong>
</span>
<button type="button" class="button icon-button" title="Edit" :aria-label="`Edit ${$store.agentEditor.activeProfile().title || $store.agentEditor.activeProfile().id}`" @click="$store.agentEditor.loadEditor($store.agentEditor.activeProfile().id, false)"><x-icon class="icon" name="edit"></x-icon></button>
<button type="button" class="button icon-button" x-show="$store.agentEditor.activeProfile().id !== 'default'" title="Edit" :aria-label="`Edit ${$store.agentEditor.activeProfile().title || $store.agentEditor.activeProfile().id}`" @click="$store.agentEditor.loadEditor($store.agentEditor.activeProfile().id, false)"><x-icon class="icon" name="edit"></x-icon></button>
</div>
</template>
<button type="button" class="button agent-manager-create" @click="$store.agentEditor.loadEditor('new-agent', true)"><x-icon class="icon" name="add"></x-icon>Create agent</button>
</div>
<div class="agent-manager-list">
<template x-for="profile in $store.agentEditor.profiles" :key="profile.id">
<template x-for="profile in $store.agentEditor.visibleProfiles" :key="profile.id">
<article class="agent-manager-card">
<div class="agent-manager-avatar" :style="`background:${$store.agentEditor.profileVisual(profile).color}`">
<img x-show="$store.agentEditor.profileVisual(profile).url" :src="$store.agentEditor.profileVisual(profile).url" :alt="`${profile.title || profile.id} avatar`">
@ -144,12 +144,26 @@
<button type="button" class="text-button" x-show="$store.agentEditor.state.profile.metadata.avatar.has_override || $store.agentEditor.draft.avatar" @click="$store.agentEditor.resetAvatar()">Reset</button>
</div>
</div>
<div class="agent-field agent-name-field">
<label for="agent-editor-name" class="agent-field-label">Agent name</label>
<input id="agent-editor-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')" required autocomplete="off" :aria-invalid="$store.agentEditor.fieldIssue('name') ? 'true' : null" :aria-describedby="$store.agentEditor.fieldIssue('name') ? 'agent-editor-name-error' : null">
<div class="agent-id-feedback" x-show="$store.agentEditor.fieldIssue('name')">
<span id="agent-editor-name-error" class="field-error" role="alert" x-text="$store.agentEditor.fieldIssue('name')?.message"></span>
<button type="button" class="text-button" x-show="$store.agentEditor.profileConflict" @click="$store.agentEditor.openConflictingProfile()">Open existing agent</button>
<div class="agent-easy-identity-fields">
<div class="agent-field agent-name-field">
<label for="agent-editor-name" class="agent-field-label">Agent name</label>
<input id="agent-editor-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')" required autocomplete="off" :aria-invalid="$store.agentEditor.fieldIssue('name') ? 'true' : null" :aria-describedby="$store.agentEditor.fieldIssue('name') ? 'agent-editor-name-error' : null">
<div class="agent-id-feedback" x-show="$store.agentEditor.fieldIssue('name')">
<span id="agent-editor-name-error" class="field-error" role="alert" x-text="$store.agentEditor.fieldIssue('name')?.message"></span>
<button type="button" class="text-button" x-show="$store.agentEditor.profileConflict" @click="$store.agentEditor.openConflictingProfile()">Open existing agent</button>
</div>
</div>
<div class="agent-field agent-model-preset">
<label for="agent-editor-easy-model-preset" class="agent-field-label">Model preset</label>
<div class="agent-model-preset-picker">
<select id="agent-editor-easy-model-preset" x-model="$store.agentEditor.draft.modelPreset">
<option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
<template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
<option :value="preset.name" x-text="preset.name"></option>
</template>
</select>
<button type="button" class="button" @click="$store.agentEditor.openPresetManager()"><x-icon class="icon" name="tune"></x-icon>Edit Presets</button>
</div>
</div>
</div>
</section>
@ -165,23 +179,52 @@
</div>
</section>
<section class="agent-easy-field agent-easy-tools">
<div class="agent-field-heading">
<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>
<section class="agent-easy-field agent-easy-capabilities">
<div class="agent-field-heading"><div><div class="agent-field-label">Capabilities</div><p>Choose the default access, or set individual items On or Off.</p></div></div>
<div class="capability-policy-group">
<div class="future-policy-control"><strong>Allow tools by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('tool') === 'allow'" @change="$store.agentEditor.setPolicyDefault('tool', $event.target.checked ? 'allow' : 'block')" aria-label="Allow tools by default"><span class="toggler"></span></label></div>
<details class="capability-accordion">
<summary><span>Choose individual tools</span><x-icon name="expand_more"></x-icon></summary>
<div class="policy-items" role="list" aria-label="Tools this agent can use">
<template x-for="tool in $store.agentEditor.standardToolCatalog" :key="tool.id">
<div class="policy-item" role="listitem"><div class="policy-item-copy"><strong x-text="tool.label"></strong><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p></div><div class="policy-state-control" role="group" :aria-label="`${tool.label} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'allow'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'default'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('tool') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'block'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'block')">Off</button></div></div>
</template>
<p class="policy-empty" x-show="!$store.agentEditor.standardToolCatalog.length">No configurable tools are available.</p>
</div>
</details>
</div>
<div class="easy-tool-list" role="list" aria-label="Tools this agent can use">
<template x-for="tool in $store.agentEditor.toolCatalog" :key="tool.id">
<label class="policy-item" role="listitem"><input type="checkbox" :checked="$store.agentEditor.isToolAllowed(tool)" :aria-label="`Allow ${tool.label}`" @change="$store.agentEditor.setEasyToolAllowed(tool.id, $event.target.checked)"><div><strong x-text="tool.label"></strong><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p></div></label>
</template>
<p class="policy-empty" x-show="!$store.agentEditor.toolCatalog.length">No configurable tools are available.</p>
<div class="capability-policy-group">
<div class="future-policy-control"><strong>Allow MCPs by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('mcp') === 'allow'" @change="$store.agentEditor.setPolicyDefault('mcp', $event.target.checked ? 'allow' : 'block')" aria-label="Allow MCPs by default"><span class="toggler"></span></label></div>
<details class="capability-accordion">
<summary><span>Choose individual MCPs</span><x-icon name="expand_more"></x-icon></summary>
<div class="policy-items" role="list" aria-label="MCP tools this agent can use">
<template x-for="tool in $store.agentEditor.mcpCatalog" :key="tool.id">
<div class="policy-item" role="listitem"><div class="policy-item-copy"><strong x-text="tool.label"></strong><p class="policy-item-description" x-show="tool.description" x-text="tool.description"></p></div><div class="policy-state-control" role="group" :aria-label="`${tool.label} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'allow'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'default'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('mcp') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'block'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'block')">Off</button></div></div>
</template>
<p class="policy-empty" x-show="!$store.agentEditor.mcpCatalog.length">No MCP tools are available.</p>
</div>
</details>
</div>
<div class="capability-policy-group">
<div class="future-policy-control"><strong>Allow skills by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('skill') === 'allow'" @change="$store.agentEditor.setPolicyDefault('skill', $event.target.checked ? 'allow' : 'block')" aria-label="Allow skills by default"><span class="toggler"></span></label></div>
<details class="capability-accordion">
<summary><span>Choose individual skills</span><x-icon name="expand_more"></x-icon></summary>
<div class="policy-items" role="list" aria-label="Skills this agent can use">
<template x-for="skill in $store.agentEditor.skillCatalog" :key="skill.path">
<div class="policy-item" role="listitem"><div class="policy-item-copy"><strong x-text="skill.name"></strong><p class="policy-item-description" x-show="skill.description" x-text="skill.description"></p><em x-show="$store.agentEditor.skillWarnings(skill).length" x-text="`Expects blocked tool: ${$store.agentEditor.skillWarnings(skill).join(', ')}`"></em></div><div class="policy-state-control" role="group" :aria-label="`${skill.name} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'allow'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'default'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('skill') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'block'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'block')">Off</button></div></div>
</template>
<p class="policy-empty" x-show="!$store.agentEditor.skillCatalog.length">No configurable skills are available.</p>
</div>
</details>
</div>
<p class="easy-skills-hint">To enable or disable skills, click Advanced.</p>
</section>
</main>
<div class="agent-advanced" x-show="$store.agentEditor.mode === 'advanced'">
<nav class="agent-advanced-nav" aria-label="Advanced editor sections">
<template x-for="item in [{id:'1',label:'Identity'},{id:'2',label:'Prompt files'},{id:'3',label:'Tools'},{id:'4',label:'Skills'},{id:'5',label:'Review'}]" :key="item.id">
<template x-for="item in [{id:'1',label:'Identity'},{id:'2',label:'Prompt files'},{id:'3',label:'Tools'},{id:'4',label:'MCPs'},{id:'5',label:'Skills'},{id:'6',label:'Review'}]" :key="item.id">
<button type="button" :class="{ active: $store.agentEditor.section === item.id }" :aria-current="$store.agentEditor.section === item.id ? 'step' : null" @click="$store.agentEditor.setSection(item.id)">
<span class="section-number" x-text="item.id"></span><span x-text="item.label"></span>
<span class="agent-status-badge compact" :class="$store.agentEditor.sectionIssues(item.id).length ? 'is-error' : 'is-unsaved'" x-show="$store.agentEditor.sectionIssues(item.id).length || $store.agentEditor.sectionDirty(item.id)"><x-icon :name="$store.agentEditor.sectionIssues(item.id).length ? 'error' : 'edit_note'"></x-icon><span x-text="$store.agentEditor.sectionIssues(item.id).length ? 'Needs attention' : 'Changed'"></span></span>
@ -232,7 +275,7 @@
</section>
<section x-show="$store.agentEditor.section === '2'" data-agent-editor-section="2" tabindex="-1" aria-labelledby="agent-section-2-title">
<header class="advanced-section-heading"><h3 id="agent-section-2-title">Prompt files</h3><p>Customize this agents prompt files. You always see the inherited version next to your version.</p></header>
<header class="advanced-section-heading"><h3 id="agent-section-2-title">Prompt files</h3><p>Choose a file and edit its prompt.</p></header>
<div class="prompt-workspace">
<aside class="prompt-browser">
<label class="compact-search"><span class="sr-only">Search prompt files</span><x-icon name="search"></x-icon><input type="search" x-model="$store.agentEditor.promptFileSearch" placeholder="Search files"></label>
@ -252,61 +295,47 @@
</aside>
<div class="prompt-editor" role="region" aria-label="Selected prompt file" tabindex="0" x-show="$store.agentEditor.selectedPromptDraft">
<div class="prompt-editor-header">
<div><strong x-text="$store.agentEditor.selectedPrompt"></strong><div class="source-chain" x-text="$store.agentEditor.promptSourceChain($store.agentEditor.selectedPromptDraft)"></div></div>
<div class="prompt-file-heading">
<strong x-text="$store.agentEditor.selectedPrompt"></strong>
<div class="prompt-customization-path"><code x-text="$store.agentEditor.promptCustomizationPath()"></code><button type="button" class="button" title="Copy customization path" aria-label="Copy customization path" @click="$store.agentEditor.copyPromptPath()"><x-icon name="content_copy"></x-icon></button></div>
</div>
<div class="prompt-actions">
<button type="button" class="btn btn-action-header cancel" x-show="$store.agentEditor.promptEditPending($store.agentEditor.selectedPromptDraft)" title="Discard current edit" aria-label="Discard current edit" @click="$store.agentEditor.discardPromptEdit($store.agentEditor.selectedPrompt)"><x-icon name="close"></x-icon></button>
<button type="button" class="btn btn-action-header confirm" x-show="$store.agentEditor.promptEditPending($store.agentEditor.selectedPromptDraft)" title="Accept current edit" aria-label="Accept current edit" @click="$store.agentEditor.acceptPromptEdit($store.agentEditor.selectedPrompt)"><x-icon name="check"></x-icon></button>
<button type="button" class="button" x-show="$store.agentEditor.selectedPromptDraft.has_override || $store.agentEditor.promptDirty($store.agentEditor.selectedPromptDraft)" @click="$store.agentEditor.resetPrompt($store.agentEditor.selectedPrompt)">Reset to default</button>
</div>
</div>
<div class="prompt-customization-path"><code x-text="$store.agentEditor.promptCustomizationPath()"></code><button type="button" class="button" title="Copy customization path" aria-label="Copy customization path" @click="$store.agentEditor.copyPromptPath()"><x-icon name="content_copy"></x-icon></button></div>
<div class="agent-editor-note" x-show="$store.agentEditor.selectedPromptDraft.dynamic_processor">This file is generated dynamically at runtime. Python processors are read-only and are not executed by the editor.</div>
<div class="prompt-view-tabs" role="tablist" aria-label="Prompt view">
<button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === ''" :class="{ active: $store.agentEditor.comparePrompt === '' }" @click="$store.agentEditor.comparePrompt = ''">Your version</button>
<button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === 'inherited'" :class="{ active: $store.agentEditor.comparePrompt === 'inherited' }" @click="$store.agentEditor.comparePrompt = 'inherited'" x-text="$store.agentEditor.inheritedLabel"></button>
<button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === 'compare'" :class="{ active: $store.agentEditor.comparePrompt === 'compare' }" @click="$store.agentEditor.comparePrompt = 'compare'">Compare</button>
</div>
<div class="prompt-find"><label><x-icon name="search"></x-icon><span class="sr-only">Search within prompt</span><input type="search" x-model="$store.agentEditor.promptTextSearch" placeholder="Find in file" @keydown.enter.prevent="$store.agentEditor.findInPrompt($event.shiftKey ? -1 : 1)"></label><span x-text="`${$store.agentEditor.promptMatchCount()} matches`"></span><button type="button" class="button icon prompt-match-action" aria-label="Previous match" @click="$store.agentEditor.findInPrompt(-1)"><x-icon name="keyboard_arrow_up"></x-icon></button><button type="button" class="button icon prompt-match-action" aria-label="Next match" @click="$store.agentEditor.findInPrompt(1)"><x-icon name="keyboard_arrow_down"></x-icon></button></div>
<div class="prompt-panes" :class="{ compare: $store.agentEditor.comparePrompt === 'compare' }">
<div class="prompt-pane" x-show="$store.agentEditor.comparePrompt !== 'inherited'"><label for="agent-editor-prompt-text">Your version</label><textarea id="agent-editor-prompt-text" spellcheck="false" x-model="$store.agentEditor.selectedPromptDraft.value" @input="$store.agentEditor.onPromptInput($store.agentEditor.selectedPrompt)" aria-label="Prompt Markdown" :aria-invalid="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'true' : null" :aria-describedby="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'agent-editor-prompt-error' : null"></textarea><span id="agent-editor-prompt-error" class="field-error" role="alert" x-show="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions')" x-text="$store.agentEditor.fieldIssue('instructions')?.message"></span></div>
<div class="prompt-pane inherited" x-show="$store.agentEditor.comparePrompt"><div class="prompt-pane-title" x-text="$store.agentEditor.inheritedLabel"></div><pre x-text="$store.agentEditor.selectedPromptDraft.inherited || '(empty)'" tabindex="0"></pre></div>
</div>
<div id="agent-editor-prompt-ace" class="prompt-ace" :aria-invalid="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'true' : null" :aria-describedby="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'agent-editor-prompt-error' : null"></div>
<div class="agent-editor-note" role="alert" x-show="$store.agentEditor.aceUnavailable">Prompt editor is unavailable. Reload Agent Zero and try again.</div>
<span id="agent-editor-prompt-error" class="field-error" role="alert" x-show="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions')" x-text="$store.agentEditor.fieldIssue('instructions')?.message"></span>
</div>
</div>
</section>
<section x-show="$store.agentEditor.section === '3'" data-agent-editor-section="3" tabindex="-1" aria-labelledby="agent-section-3-title">
<header class="advanced-section-heading"><h3 id="agent-section-3-title">Tools</h3><p>Choose which tools this agent can use.</p></header>
<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>
<fieldset class="policy-editor" :disabled="$store.agentEditor.draft.toolPolicy.mode !== 'custom'">
<legend class="sr-only">Tool access selection</legend>
<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>
<div class="policy-lists">
<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"><label 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></label></template></div></section>
<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>
<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"><label 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></label></template></div></section>
</div>
<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>
</fieldset>
<div class="future-policy-control"><strong>Allow tools by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('tool') === 'allow'" @change="$store.agentEditor.setPolicyDefault('tool', $event.target.checked ? 'allow' : 'block')" aria-label="Allow tools by default"><span class="toggler"></span></label></div>
<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>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>
<section class="policy-group" aria-labelledby="tools-title"><header><h4 id="tools-title">Tools</h4><span x-text="$store.agentEditor.filteredTools('tool').length"></span></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools('tool').length">No tools match these filters.</p><template x-for="tool in $store.agentEditor.filteredTools('tool')" :key="tool.id"><div class="policy-item"><div class="policy-item-copy"><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 === false">Unavailable — kept in your settings</em></div><div class="policy-state-control" role="group" :aria-label="`${tool.label} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'allow'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'default'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('tool') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('tool', tool.id) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('tool', tool.id) === 'block'" @click="$store.agentEditor.setPolicyItem('tool', tool.id, 'block')">Off</button></div></div></template></div></section>
</section>
<section x-show="$store.agentEditor.section === '4'" data-agent-editor-section="4" tabindex="-1" aria-labelledby="agent-section-4-title">
<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>
<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>
<fieldset class="policy-editor" :disabled="$store.agentEditor.draft.skillPolicy.mode !== 'custom'">
<legend class="sr-only">Skill access selection</legend>
<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>
<div class="policy-lists">
<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"><label 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></label></template></div></section>
<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>
<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"><label 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></label></template></div></section>
</div>
<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>
</fieldset>
<header class="advanced-section-heading"><h3 id="agent-section-4-title">MCPs</h3><p>Choose which MCP tools this agent can use.</p></header>
<div class="future-policy-control"><strong>Allow MCPs by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('mcp') === 'allow'" @change="$store.agentEditor.setPolicyDefault('mcp', $event.target.checked ? 'allow' : 'block')" aria-label="Allow MCPs by default"><span class="toggler"></span></label></div>
<div class="policy-filters tools"><label class="policy-search"><x-icon name="search"></x-icon><span class="sr-only">Search MCPs</span><input type="search" x-model="$store.agentEditor.toolSearch" placeholder="Search MCPs"></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>
<section class="policy-group" aria-labelledby="mcps-title"><header><h4 id="mcps-title">MCPs</h4><span x-text="$store.agentEditor.filteredTools('mcp').length"></span></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredTools('mcp').length">No MCP tools match these filters.</p><template x-for="tool in $store.agentEditor.filteredTools('mcp')" :key="tool.id"><div class="policy-item"><div class="policy-item-copy"><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 === false">Unavailable — kept in your settings</em></div><div class="policy-state-control" role="group" :aria-label="`${tool.label} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'allow'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'default'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('mcp') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('mcp', tool.id) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('mcp', tool.id) === 'block'" @click="$store.agentEditor.setPolicyItem('mcp', tool.id, 'block')">Off</button></div></div></template></div></section>
</section>
<section x-show="$store.agentEditor.section === '5'" data-agent-editor-section="5" tabindex="-1" aria-labelledby="agent-section-5-title">
<header class="advanced-section-heading"><h3 id="agent-section-5-title">Review</h3><p>Saving will change exactly these files — nothing else.</p></header>
<header class="advanced-section-heading"><h3 id="agent-section-5-title">Skills</h3><p>Choose which skills this agent can find and use.</p></header>
<div class="future-policy-control"><strong>Allow skills by default</strong><label class="toggle"><input type="checkbox" :checked="$store.agentEditor.policyDefault('skill') === 'allow'" @change="$store.agentEditor.setPolicyDefault('skill', $event.target.checked ? 'allow' : 'block')" aria-label="Allow skills by default"><span class="toggler"></span></label></div>
<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>
<section class="policy-group" aria-labelledby="skills-title"><header><h4 id="skills-title">Skills</h4><span x-text="$store.agentEditor.filteredSkills().length"></span></header><div class="policy-items"><p class="policy-empty" x-show="!$store.agentEditor.filteredSkills().length">No skills match these filters.</p><template x-for="skill in $store.agentEditor.filteredSkills()" :key="skill.path"><div class="policy-item"><div class="policy-item-copy"><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 class="policy-state-control" role="group" :aria-label="`${skill.name} access`"><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'allow' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'allow'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'allow')">On</button><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'default' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'default'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'default')" x-text="`Default (${$store.agentEditor.policyDefault('skill') === 'allow' ? 'on' : 'off'})`"></button><button type="button" :class="{ active: $store.agentEditor.policyItemState('skill', skill.name) === 'block' }" :aria-pressed="$store.agentEditor.policyItemState('skill', skill.name) === 'block'" @click="$store.agentEditor.setPolicyItem('skill', skill.name, 'block')">Off</button></div></div></template></div></section>
</section>
<section x-show="$store.agentEditor.section === '6'" data-agent-editor-section="6" tabindex="-1" aria-labelledby="agent-section-6-title">
<header class="advanced-section-heading"><h3 id="agent-section-6-title">Review</h3><p>Saving will change exactly these files — nothing else.</p></header>
<div class="review-plan-status" role="status" x-show="$store.agentEditor.planStatus === 'loading'"><x-icon class="spinning" name="progress_activity"></x-icon><span>Computing the exact change plan…</span></div>
<div class="review-plan-status" x-show="$store.agentEditor.planStatus === 'error'"><span>The change plan could not be computed.</span><button type="button" class="text-button" @click="$store.agentEditor.previewPlan()">Retry</button></div>
<div class="review-blocked" role="alert" x-show="$store.agentEditor.planStatus === 'blocked'">
@ -366,11 +395,7 @@
.agent-editor h2,.agent-editor h3,.agent-editor h4,.agent-editor p { margin: 0; }
.agent-editor button,.agent-editor input,.agent-editor textarea,.agent-editor select { font: inherit; }
.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); }
.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); }
.agent-editor input[type="checkbox"] { display:grid; place-content:center; border-radius:4px; }
.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); }
.agent-editor input[type="checkbox"]:checked { border-color:var(--agent-editor-action); background:var(--agent-editor-action); }
.agent-editor input[type="checkbox"]:checked::before { opacity:1; }
.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); }
.agent-editor input[type="radio"] { border-radius:50%; }
.agent-editor input[type="radio"]:checked { border-color:var(--agent-editor-action); box-shadow:inset 0 0 0 4px var(--color-input); background:var(--agent-editor-action); }
.agent-editor-error { display:flex; gap:.55rem; align-items:flex-start; margin:0 0 .8rem; padding:.7rem .8rem; border:1px solid color-mix(in srgb,var(--agent-editor-danger) 55%,var(--color-border)); border-radius:8px; background:color-mix(in srgb,var(--agent-editor-danger) 10%,var(--color-panel)); }
@ -390,7 +415,8 @@
.agent-mode-switch button { border:0; border-radius:6px; padding:.42rem .8rem; color:var(--color-text-secondary); background:transparent; }
.agent-mode-switch button.active { color:var(--color-text); background:var(--color-panel); box-shadow:0 1px 4px rgba(0,0,0,.2); }
.agent-easy { max-width:43rem; margin:0 auto; display:flex; flex-direction:column; gap:1.35rem; padding:.25rem .25rem 1rem; }
.agent-easy-identity { position:relative; display:grid; grid-template-columns:7.2rem 1fr; gap:1rem; align-items:center; }
.agent-easy-identity { position:relative; display:grid; grid-template-columns:7.2rem 1fr; gap:1rem; align-items:start; }
.agent-easy-identity-fields { width:100%; min-width:0; display:flex; flex-direction:column; gap:.9rem; }
.agent-avatar-wrap { display:flex; flex-direction:column; align-items:center; gap:.45rem; }
.agent-avatar { width:5rem; aspect-ratio:1; position:relative; display:grid; place-items:center; border-radius:16px; overflow:hidden; color:white; font-size:1.35rem; font-weight:700; box-shadow:inset 0 0 0 1px rgba(255,255,255,.15); }
.agent-avatar img { width:100%; height:100%; object-fit:cover; }
@ -401,9 +427,9 @@
.avatar-action-icon x-icon { font-size:1.05rem; }
.avatar-color-action input,.avatar-upload-action input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
.agent-field { display:flex; flex-direction:column; gap:.35rem; min-width:0; }
.agent-name-field { align-self:start; }
.agent-name-field { width:100%; }
.agent-field-label { font-weight:650; font-size:.92rem; }
.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; }
.agent-field input,.agent-field textarea,.agent-easy textarea,.policy-filters input,.policy-filters select,.compact-search input { width:100%; box-sizing:border-box; }
.agent-field small,.agent-field-heading p,.field-status { color:var(--color-text-secondary); font-size:.79rem; }
.field-count { color:var(--color-text-secondary); font-size:.79rem; font-weight:400; }
.field-status { display:block; margin-top:.15rem; }
@ -415,16 +441,22 @@
.agent-editor .text-button:hover { text-decoration:underline; }
.agent-easy textarea { min-height:11rem; resize:vertical; }
.restore-action { --agent-editor-action:var(--color-primary); display:inline-flex; align-items:center; gap:.25rem; margin-top:.4rem; }
.easy-tool-list { display:flex; flex-direction:column; padding:.2rem .35rem .2rem 0; }
.easy-tool-list .policy-item { cursor:pointer; }
.easy-skills-hint { margin:.55rem 0 0; color:var(--color-text-secondary); font-size:.79rem; }
.agent-advanced { display:grid; grid-template-columns:14rem minmax(0,1fr); gap:1rem; min-height:0; }
.agent-easy-capabilities { display:flex; flex-direction:column; gap:.65rem; }
.future-policy-control { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.45rem .2rem; }
.capability-policy-group + .capability-policy-group { border-top:1px solid var(--color-border); padding-top:.55rem; }
.capability-accordion summary { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.65rem .2rem .75rem; cursor:pointer; color:var(--color-text-secondary); font-weight:500; }
.capability-accordion summary x-icon { color:var(--color-text-secondary); transition:transform .15s ease; }
.capability-accordion[open] summary x-icon { transform:rotate(180deg); }
.capability-accordion .policy-items { max-height:none; overflow:visible; padding:0 0 .45rem; }
.agent-advanced { flex:1 1 auto; display:grid; grid-template-columns:14rem minmax(0,1fr); gap:1rem; min-height:0; }
.agent-advanced-nav { display:flex; flex-direction:column; gap:.3rem; padding:.45rem; border:1px solid var(--color-border); border-radius:10px; background:var(--color-input); align-self:start; position:sticky; top:0; }
.agent-advanced-nav button { display:grid; grid-template-columns:1.7rem 1fr auto; align-items:center; gap:.45rem; border:0; border-radius:7px; padding:.65rem; color:var(--color-text-secondary); background:transparent; text-align:left; }
.agent-advanced-nav button.active { color:var(--color-text); background:var(--color-panel); }
.section-number { display:grid; place-items:center; width:1.55rem; height:1.55rem; border:1px solid var(--color-border); border-radius:50%; font-size:.75rem; }
.agent-advanced-content,.agent-editor-workspace,.agent-manager { min-width:0; }
.agent-advanced-content { min-height:0; overflow:auto; }
.agent-advanced-content > section { outline:none; display:flex; flex-direction:column; gap:1rem; }
.agent-advanced-content > section[data-agent-editor-section="2"] { height:100%; min-height:0; }
.advanced-section-heading { display:flex; flex-direction:column; gap:.25rem; padding-bottom:.8rem; border-bottom:1px solid var(--color-border); }
.advanced-section-heading h3 { font-size:1.2rem; }
.advanced-section-heading p { max-width:48rem; color:var(--color-text-secondary); font-size:.84rem; }
@ -439,12 +471,12 @@
.agent-model-preset-picker { width:100%; display:grid; grid-template-columns:minmax(13rem,18rem) auto; align-items:center; justify-content:space-between; gap:1rem; }
.agent-model-preset-picker select { width:100%; }
.agent-model-preset-picker > .button { display:inline-flex; align-items:center; gap:.35rem; }
.prompt-workspace { display:grid; grid-template-columns:minmax(13rem,16rem) minmax(0,1fr); gap:.8rem; min-width:0; height:clamp(34rem,65vh,58rem); }
.prompt-workspace { flex:1 1 auto; display:grid; grid-template-columns:minmax(13rem,16rem) minmax(0,1fr); gap:.8rem; min-width:0; min-height:0; }
.prompt-browser { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
.compact-search,.policy-search,.prompt-find label { display:grid; grid-template-columns:1.25rem minmax(0,1fr); align-items:center; gap:.3rem; min-width:0; height:2.125rem; padding:0 .45rem; border:1px solid color-mix(in srgb,var(--color-border) 42%,transparent); border-radius:7px; background:color-mix(in srgb,var(--color-text) 10%,transparent); color:var(--color-text-secondary); }
.compact-search,.policy-search { display:grid; grid-template-columns:1.25rem minmax(0,1fr); align-items:center; gap:.3rem; min-width:0; height:2.125rem; padding:0 .45rem; border:1px solid color-mix(in srgb,var(--color-border) 42%,transparent); border-radius:7px; background:color-mix(in srgb,var(--color-text) 10%,transparent); color:var(--color-text-secondary); }
.compact-search { margin:.45rem; }
.compact-search:focus-within,.policy-search:focus-within,.prompt-find label:focus-within { border-color:color-mix(in srgb,var(--color-primary) 46%,var(--color-border)); background:color-mix(in srgb,var(--color-text) 13%,transparent); }
.agent-editor .compact-search input,.agent-editor .policy-search input,.agent-editor .prompt-find label input { height:100%; min-height:0; padding:0; border:0; outline:0; appearance:none; background:transparent; }
.compact-search:focus-within,.policy-search:focus-within { border-color:color-mix(in srgb,var(--color-primary) 46%,var(--color-border)); background:color-mix(in srgb,var(--color-text) 13%,transparent); }
.agent-editor .compact-search input,.agent-editor .policy-search input { height:100%; min-height:0; padding:0; border:0; outline:0; appearance:none; background:transparent; }
.prompt-file-list { flex:1; overflow:auto; padding:.35rem; }
.prompt-file-group { min-width:0; margin-bottom:.55rem; }
.prompt-file-group h4 { padding:.35rem .5rem .25rem; color:var(--color-text-secondary); font-size:.7rem; font-weight:650; letter-spacing:.03em; text-transform:uppercase; }
@ -453,57 +485,36 @@
.prompt-file-name { grid-column:1/-1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:monospace; font-size:.76rem; }
.prompt-file-state { color:var(--color-text-secondary); font-size:.7rem; }
.prompt-empty { padding:.65rem; color:var(--color-text-secondary); font-size:.78rem; }
.prompt-editor { display:flex; flex-direction:column; gap:.55rem; min-width:0; max-width:100%; max-height:100%; overflow:auto; }
.prompt-editor { display:flex; flex-direction:column; gap:.55rem; min-width:0; min-height:0; max-width:100%; overflow:hidden; }
.prompt-editor-header { display:flex; justify-content:space-between; gap:.7rem; align-items:flex-start; }
.prompt-editor-header > div:first-child { min-width:0; overflow-wrap:anywhere; }
.source-chain { margin-top:.2rem; color:var(--color-text-secondary); font-size:.75rem; }
.prompt-file-heading { display:flex; flex-direction:column; gap:.25rem; }
.prompt-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:.35rem; min-width:0; }
.prompt-actions .button { max-width:100%; white-space:normal; overflow-wrap:anywhere; }
.prompt-customization-path { display:flex; align-items:center; justify-content:flex-end; gap:.4rem; min-width:0; }
.prompt-customization-path code { min-width:0; color:var(--color-text-secondary); font-size:.72rem; overflow-wrap:anywhere; text-align:right; }
.prompt-customization-path { display:flex; align-items:center; gap:.4rem; min-width:0; }
.prompt-customization-path code { min-width:0; color:var(--color-text-secondary); font-size:.72rem; overflow-wrap:anywhere; }
.prompt-customization-path .button { width:2.125rem; height:2.125rem; flex:0 0 auto; padding:0; }
.prompt-view-tabs { display:flex; flex-wrap:wrap; gap:.25rem; padding-bottom:.35rem; border-bottom:1px solid var(--color-border); }
.prompt-view-tabs button { min-height:2rem; padding:.35rem .65rem; border:0; border-radius:6px; background:transparent; color:var(--color-text-secondary); }
.prompt-view-tabs button.active { background:var(--color-input); color:var(--color-text); }
.prompt-find { display:flex; flex-wrap:wrap; align-items:center; gap:.35rem; min-width:0; }
.prompt-find label { flex:1 1 10rem; min-width:0; }
.prompt-find span { color:var(--color-text-secondary); font-size:.75rem; white-space:nowrap; }
.prompt-find .prompt-match-action { flex:0 0 2.125rem; width:2.125rem; height:2.125rem; min-height:2.125rem; padding:0; border-radius:7px; justify-content:center; }
.prompt-panes { display:grid; grid-template-columns:1fr; gap:.65rem; min-height:18rem; }
.prompt-panes.compare { grid-template-columns:1fr 1fr; }
.prompt-pane { min-width:0; min-height:0; display:flex; flex-direction:column; gap:.35rem; }
.prompt-pane label,.prompt-pane-title { color:var(--color-text-secondary); font-size:.75rem; }
.prompt-pane textarea,.prompt-pane pre { flex:1; box-sizing:border-box; width:100%; min-height:0; margin:0; padding:.75rem; overflow:auto; border:1px solid var(--color-border); border-radius:8px; background:var(--color-input); color:var(--color-text); font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; tab-size:2; resize:vertical; }
.prompt-pane textarea:focus-visible { outline-offset:-2px; }
.prompt-pane.inherited pre { opacity:.86; }
.prompt-ace { flex:1; min-height:0; border:1px solid var(--color-border); border-radius:8px; overflow:hidden; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
.profile-maintenance summary { display:flex; align-items:center; min-height:1.5rem; cursor:pointer; }
.policy-mode-row,.future-default { display:flex; flex-wrap:wrap; align-items:center; gap:.7rem 1rem; padding:.7rem; border:1px solid var(--color-border); border-radius:9px; }
.policy-mode-row label,.future-default label { display:flex; gap:.35rem; align-items:center; }
.policy-mode-row small { color:var(--color-text-secondary); }
.policy-editor { min-width:0; margin:.75rem 0 0; padding:0; border:0; transition:opacity .15s ease; }
.policy-editor:disabled { opacity:.48; }
.future-default { margin-top:.8rem; }
.future-default legend { padding:0 .35rem; color:var(--color-text-secondary); font-size:.8rem; }
.policy-filters { display:grid; grid-template-columns:minmax(12rem,1fr) minmax(8rem,auto) minmax(8rem,auto); gap:.6rem; margin-bottom:.7rem; }
.policy-filters { display:grid; grid-template-columns:minmax(12rem,1fr) minmax(8rem,auto); gap:.6rem; }
.policy-filters.skills { grid-template-columns:minmax(12rem,1fr) minmax(8rem,auto); }
.policy-filters label { display:flex; align-items:center; gap:.35rem; font-size:.78rem; color:var(--color-text-secondary); }
.policy-filters select { flex:1; }
.policy-lists { display:grid; grid-template-columns:minmax(0,1fr) 2.5rem minmax(0,1fr); gap:.55rem; }
.policy-transfer-actions { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.5rem; }
.policy-transfer-actions .button { width:2.5rem; height:2.5rem; padding:0; justify-content:center; }
.policy-list { display:flex; flex-direction:column; min-width:0; min-height:28rem; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
.policy-list header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.5rem; min-height:3rem; box-sizing:border-box; padding:.7rem; border-bottom:1px solid var(--color-border); background:var(--color-input); }
.policy-list h4 { display:inline; margin-right:.35rem; }
.policy-list header span { color:var(--color-text-secondary); font-size:.72rem; }
.policy-items { flex:1; max-height:32rem; overflow:auto; padding:.35rem; }
.policy-item { display:flex; gap:.55rem; align-items:flex-start; padding:.55rem; border-radius:7px; }
.policy-group { display:flex; flex-direction:column; min-width:0; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
.policy-group header { display:flex; align-items:center; justify-content:space-between; gap:.5rem; padding:.65rem .8rem; border-bottom:1px solid var(--color-border); background:var(--color-input); }
.policy-group header span { color:var(--color-text-secondary); font-size:.72rem; }
.policy-items { flex:1; max-height:24rem; overflow:auto; padding:.35rem; }
.policy-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.75rem; align-items:start; padding:.55rem; border-radius:7px; }
.policy-item:hover { background:var(--color-input); }
.policy-item > div { display:flex; flex:1; flex-direction:column; gap:.15rem; min-width:0; }
.policy-item-copy { display:flex; flex-direction:column; gap:.15rem; min-width:0; }
.policy-items small { color:var(--color-text-secondary); overflow-wrap:anywhere; }
.policy-items em { color:var(--color-warning); font-size:.72rem; font-style:normal; }
.policy-item-description { margin:0; color:var(--color-text-secondary); font-size:.75rem; white-space:pre-wrap; overflow-wrap:anywhere; }
.policy-state-control { display:inline-flex; align-self:center; overflow:hidden; border:1px solid var(--color-border); border-radius:var(--border-radius-sm); background:var(--color-input); }
.policy-state-control button { min-height:2rem; padding:.35rem .6rem; border:0; border-left:1px solid var(--color-border); border-radius:0; background:transparent; color:var(--color-text-secondary); cursor:pointer; font-size:.74rem; white-space:nowrap; }
.policy-state-control button:first-child { border-left:0; }
.policy-state-control button.active { color:var(--color-text); background:color-mix(in srgb,var(--agent-editor-action) 16%,var(--color-input)); box-shadow:inset 0 0 0 1px var(--agent-editor-action); }
.policy-empty { padding:1rem .7rem; color:var(--color-text-secondary); font-size:.8rem; text-align:center; }
.agent-editor .policy-bulk { min-height:1.5rem; padding:.1rem .45rem; font-size:.72rem; line-height:1.15; white-space:normal; }
.review-identity { display:grid; grid-template-columns:3.25rem minmax(0,1fr); align-items:center; gap:.8rem; padding:.8rem; border-bottom:1px solid var(--color-border); }
.review-identity-avatar { width:3.25rem; aspect-ratio:1; display:grid; place-items:center; overflow:hidden; border-radius:10px; color:#fff; font-weight:700; }
.review-identity-avatar img { width:100%; height:100%; object-fit:cover; }
@ -562,9 +573,9 @@
.agent-editor :focus-visible { outline:2px solid var(--agent-editor-action); outline-offset:2px; }
.modal-inner.agent-editor-easy { width:min(720px,calc(100vw - 2rem)); max-width:720px; }
.modal-inner.agent-editor-advanced { width:92vw; max-width:none; height:min(calc(100vh - 4rem),80rem); max-height:calc(100vh - 4rem); }
.modal-inner.agent-editor-advanced .modal-scroll,.modal-inner.agent-editor-advanced .modal-bd,.modal-inner.agent-editor-advanced .agent-editor,.modal-inner.agent-editor-advanced .agent-editor-workspace { display:flex; flex:1 1 auto; min-height:0; }
.modal-inner.agent-editor-advanced .modal-scroll { max-height:none; }
.modal-inner.agent-editor-advanced .modal-bd { min-height:0; }
.modal-inner.agent-editor-advanced .agent-editor,.modal-inner.agent-editor-advanced .agent-editor-workspace { min-height:100%; }
.modal-inner.agent-editor-advanced .agent-editor,.modal-inner.agent-editor-advanced .agent-editor-workspace { flex-direction:column; }
@media (max-width: 760px) {
.modal-inner.agent-editor-easy,.modal-inner.agent-editor-advanced { width:100vw; max-width:none; height:100vh; max-height:none; border-radius:0; }
.agent-editor-topbar { align-items:flex-start; flex-wrap:wrap; }
@ -572,21 +583,22 @@
.agent-editor-topbar-actions { width:100%; flex-wrap:wrap; justify-content:flex-end; }
.agent-editor-scope { flex:1 1 14rem; }
.agent-easy-identity { grid-template-columns:1fr; justify-items:center; padding-top:1.8rem; }
.agent-name-field { width:100%; }
.agent-id-feedback { width:100%; }
.agent-advanced { grid-template-columns:1fr; }
.agent-advanced-nav { position:static; flex-direction:row; overflow-x:auto; }
.agent-advanced-nav button { grid-template-columns:auto auto auto; white-space:nowrap; }
.agent-model-preset-picker { grid-template-columns:1fr; gap:.5rem; }
.prompt-workspace { grid-template-columns:1fr; height:auto; }
.agent-model-preset-picker { grid-template-columns:minmax(0,1fr) auto; gap:.5rem; }
.prompt-workspace { grid-template-columns:1fr; }
.prompt-browser { height:22rem; max-height:22rem; }
.prompt-editor { max-height:none; overflow:visible; }
.prompt-panes.compare,.policy-lists,.change-plan,.advanced-identity-grid,.identity-fields { grid-template-columns:1fr; }
.policy-lists .policy-transfer-actions { flex-direction:row; }
.policy-lists .policy-transfer-actions x-icon { transform:rotate(90deg); }
.prompt-ace { min-height:18rem; }
.change-plan,.advanced-identity-grid,.identity-fields { grid-template-columns:1fr; }
.identity-fields .agent-field.wide { grid-column:1; }
.policy-filters { grid-template-columns:1fr; }
.policy-list { min-height:20rem; }
.future-policy-control { align-items:flex-start; }
.policy-item { grid-template-columns:1fr; }
.policy-state-control { width:100%; align-self:stretch; }
.policy-state-control button { flex:1; }
.agent-manager-list-header { flex-wrap:wrap; }
.active-agent-display { width:100%; }
.agent-manager-create { margin-left:auto; }

View file

@ -16,6 +16,8 @@
profile `config.json` files, projects may own project or project-profile
configs through the standard plugin scope paths, and the runtime remains
authoritative.
- Custom configuration stores independent `default` and `mcp_default`
fallbacks; explicit canonical IDs remain shared in `allowed` and `blocked`.
- Required final-response capability is never disabled.
## Verification

View file

@ -1,4 +1,5 @@
mode: inherit
default: allow
mcp_default: allow
allowed: []
blocked: []

View file

@ -329,6 +329,7 @@ def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> N
"tool_policy": {
"mode": "custom",
"default": "block",
"mcp_default": "allow",
"allowed": ["local:search_engine"],
"blocked": ["local:shell"],
},
@ -344,7 +345,7 @@ def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> N
assert json.loads(model.read_text())["manual"] == 1
assert set(json.loads(tools.read_text())) >= {
"manual", "mode", "default", "allowed", "blocked"
"manual", "mode", "default", "mcp_default", "allowed", "blocked"
}
skill_data = json.loads(skill.read_text())
assert skill_data["active_skills"] == [{"name": "existing"}]
@ -379,6 +380,7 @@ def test_model_and_off_tool_choices_write_only_their_json_contracts(
assert json.loads(off.changes[tool_path].content) == {
"mode": "custom",
"default": "block",
"mcp_default": "block",
"allowed": [],
"blocked": [],
}
@ -419,6 +421,44 @@ def test_project_tool_policy_reads_effective_access_and_writes_project_scope(
]
def test_tri_state_tool_mcp_and_skill_policies_write_at_both_scopes(
user_root: Path,
project_scope: tuple[editor._EditorContext, Path],
) -> None:
tool_policy = {
"mode": "custom",
"default": "block",
"mcp_default": "allow",
"allowed": ["local:shell"],
"blocked": ["mcp:docs:write"],
}
skill_policy = {
"mode": "custom",
"default": "allow",
"allowed": ["Research"],
"blocked": ["Unsafe"],
}
patch = {
"profile_id": "researcher",
"tool_policy": tool_policy,
"skill_policy": skill_policy,
}
context, project_agents = project_scope
for plan, root in (
(editor.build_change_plan(patch), user_root),
(editor.build_change_plan(patch, context), project_agents),
):
editor.apply_change_plan(plan)
profile_root = root / "researcher" / "plugins"
assert json.loads(
(profile_root / "_tool_access" / "config.json").read_text()
) == tool_policy
assert json.loads(
(profile_root / "_skills" / "config.json").read_text()
)["visibility_policy"] == skill_policy
def test_project_agents_are_scope_owned_and_never_leak_global_writes(
user_root: Path,
project_scope: tuple[editor._EditorContext, Path],

View file

@ -33,11 +33,16 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
modal,
re.DOTALL,
).group(0)
skill_section = re.search(
mcp_section = re.search(
r'data-agent-editor-section="4".*?(?=<section x-show="\$store\.agentEditor\.section === \'5\'")',
modal,
re.DOTALL,
).group(0)
skill_section = re.search(
r'data-agent-editor-section="5".*?(?=<section x-show="\$store\.agentEditor\.section === \'6\'")',
modal,
re.DOTALL,
).group(0)
easy_surface = modal.split('<div class="agent-advanced"', 1)[0]
assert "Create agent" not in switcher
@ -59,43 +64,50 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
"Identity",
"Prompt files",
"Tools",
"MCPs",
"Skills",
"Review",
"Save & test",
)
)
assert 'aria-label="Editor mode"' in modal
assert "Allow selected" in modal and "Block selected" in modal
assert "Allow selected" not in modal and "Block selected" not in modal
assert "No optional tools" not in modal
assert 'class="agent-model-preset-picker"' in modal
assert 'x-model="$store.agentEditor.draft.modelPreset"' in modal
assert modal.count('x-model="$store.agentEditor.draft.modelPreset"') == 2
assert 'id="agent-editor-easy-model-preset"' in easy_surface
easy_identity = easy_surface[easy_surface.index('<section class="agent-easy-identity">'):easy_surface.index('<section class="agent-easy-field">')]
assert easy_identity.index('id="agent-editor-name"') < easy_identity.index('id="agent-editor-easy-model-preset"')
assert easy_surface.index('id="agent-editor-easy-model-preset"') < easy_surface.index('id="agent-editor-instructions"')
assert '`Use current preset (${$store.agentEditor.state.model_preset.effective})`' in modal
assert 'x-for="preset in $store.agentEditor.state.model_presets"' in modal
assert "Edit Presets" in modal
assert modal.count("Edit Presets") == 2
assert "Manage presets" not in modal
assert "model-preset-row" not in modal
assert 'class="easy-tool-details"' not in modal
assert 'x-for="tool in $store.agentEditor.toolCatalog"' in easy_surface
assert 'class="field-count"' in easy_surface
assert "toolCatalog.length === 1 ? 'tool' : 'tools'" in modal
assert "skillCatalog.length === 1 ? 'skill' : 'skills'" in modal
assert ':checked="$store.agentEditor.isToolAllowed(tool)"' in easy_surface
assert "$store.agentEditor.setEasyToolAllowed(tool.id, $event.target.checked)" in easy_surface
assert easy_surface.count('<details class="capability-accordion">') == 3
assert re.findall(r'<summary><span>(Choose individual (?:tools|MCPs|skills))</span><x-icon name="expand_more"></x-icon></summary>', easy_surface) == ["Choose individual tools", "Choose individual MCPs", "Choose individual skills"]
assert '<details class="capability-accordion" open' not in easy_surface
assert 'x-for="tool in $store.agentEditor.standardToolCatalog"' in easy_surface
assert 'x-for="skill in $store.agentEditor.skillCatalog"' in easy_surface
assert 'x-for="tool in $store.agentEditor.mcpCatalog"' in easy_surface
assert "Choose tools in Advanced" not in modal
assert "To enable or disable skills, click Advanced." in easy_surface
assert 'x-for="skill in $store.agentEditor' not in easy_surface
assert 'class="easy-tool-actions"' not in modal
assert 'class="policy-editor" :disabled="$store.agentEditor.draft.toolPolicy.mode !== \'custom\'"' in tool_section
assert 'class="policy-editor" :disabled="$store.agentEditor.draft.skillPolicy.mode !== \'custom\'"' in skill_section
assert 'class="policy-lists"' in tool_section and 'class="policy-lists"' in skill_section
assert 'aria-label="Block selected tools"' in tool_section
assert 'aria-label="Allow selected tools"' in tool_section
assert 'aria-label="Block selected skills"' in skill_section
assert 'aria-label="Allow selected skills"' in skill_section
assert "Use standard tool access" not in modal
assert "Use standard skill access" not in modal
assert 'class="policy-lists"' not in modal
assert "policy-transfer-actions" not in modal
assert tool_section.count('class="policy-group"') == 1
assert mcp_section.count('class="policy-group"') == 1
assert skill_section.count('class="policy-group"') == 1
assert '<label>Category ' not in tool_section
assert "indeterminate" not in modal
assert modal.count('class="policy-state-control" role="group"') == 6
assert modal.count(':aria-pressed="$store.agentEditor.policyItemState') == 18
assert '<details class="policy-description"' not in tool_section
assert '<details class="policy-description"' not in mcp_section
assert '<details class="policy-description"' not in skill_section
assert tool_section.count('class="policy-item-description"') == 2
assert skill_section.count('class="policy-item-description"') == 2
assert tool_section.count('class="policy-item-description"') == 1
assert mcp_section.count('class="policy-item-description"') == 1
assert skill_section.count('class="policy-item-description"') == 1
assert "Your changes override the built-in profile. The original files stay unchanged." in modal
assert 'x-model="$store.agentEditor.projectName"' in modal
assert 'x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"' in modal
@ -148,7 +160,11 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert "Create agents and customize inherited profiles" not in modal
assert "Unavailable — kept in your settings" in modal
assert "Customize this file" not in modal
assert 'role="tablist" aria-label="Prompt view"' in modal
assert "Choose a file and edit its prompt." in modal
assert "inherited version next to your version" not in modal
assert 'role="tablist" aria-label="Prompt view"' not in modal
assert "Your version" not in modal and ">Compare</button>" not in modal
assert "Find in file" not in modal and "prompt-match-action" not in modal
assert "No prompt files match your search." in modal
assert "Saving will change exactly these files — nothing else." in modal
assert "Review & test" not in modal
@ -161,15 +177,21 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert "<h2" not in modal
assert "Section 1" not in modal
assert '<textarea id="agent-editor-description" rows="2"' in modal
assert "When new tools are installed later" in modal
assert "When new skills are installed later" in modal
assert "Block until reviewed" in modal
assert "No blocked tools" in modal and "No blocked skills" in modal
assert "Allow newly installed" not in modal
assert modal.count("<strong>Allow tools by default</strong>") == 2
assert modal.count("<strong>Allow MCPs by default</strong>") == 2
assert modal.count("<strong>Allow skills by default</strong>") == 2
assert "Allow tools and MCPs by default" not in modal
assert "Applies to Tools and MCPs left on Default." not in modal
assert "Applies to Skills left on Default." not in modal
assert "No blocked tools" not in modal and "No blocked skills" not in modal
assert "policy-description" not in modal and "-webkit-line-clamp:2" not in modal
assert 'class="prompt-file-list" role="region" aria-label="Prompt file list" tabindex="0"' in modal
assert 'class="prompt-editor" role="region" aria-label="Selected prompt file" tabindex="0"' in modal
assert "Preview combined prompt" not in modal
assert 'class="prompt-customization-path"' in modal
assert '<strong x-text="$store.agentEditor.selectedPrompt"></strong>\n <div class="prompt-customization-path">' in modal
assert "source-chain" not in modal and "promptSourceChain" not in store
assert "$store.agentEditor.promptCustomizationPath()" in modal
assert 'class="review-identity"' in modal
assert 'aria-label="Agent identity"' in modal
@ -178,10 +200,16 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert "draft.profileId\"></code>" not in review_identity
assert "width:92vw" in modal
assert ".modal-inner.agent-editor-advanced .modal-scroll { max-height:none; }" in modal
assert '.agent-advanced-content > section[data-agent-editor-section="2"] { height:100%; min-height:0; }' in modal
assert ".prompt-workspace { flex:1 1 auto;" in modal
assert 'width:1.5rem; height:1.5rem' in modal
assert modal.count('class="button icon prompt-match-action"') == 2
assert "min-height:3rem" in modal and "font-size:.72rem" in modal
assert "moveAllVisibleTools(false)" in modal and "moveAllVisibleSkills(false)" in modal
assert 'id="agent-editor-prompt-ace"' in modal
assert 'id="agent-editor-prompt-text"' not in modal
assert "globalThis.ace.edit(container)" in store
assert 'editor.session.setMode("ace/mode/markdown")' in store
assert "editor.session.setUseWrapMode(true)" in store
assert "showPrintMargin: false" in store and "useWorker: false" in store
assert "findInPrompt" not in store and "promptTextSearch" not in store
assert 'class="agent-editor-heading"' in modal
assert ':aria-invalid=' in modal
assert modal.count('role="alert"') >= 4
@ -191,9 +219,8 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert 'x-show="$store.agentEditor.view === \'editor\' && !$store.agentEditor.draft?.creating"' in modal
assert 'class="btn btn-ok" x-show="$store.agentEditor.view === \'editor\'"' in modal
assert "Delete all customizations in" in modal
assert 'input[type="checkbox"]' in modal and "appearance:none" in modal
assert '.agent-editor input[type="checkbox"]' not in modal
assert 'promptDisplayState(prompt)' in modal
assert 'promptSourceChain($store.agentEditor.selectedPromptDraft)' in modal
assert "Will reset to default on save." in modal
assert "metadataProvenance('description')" not in modal
assert 'x-show="$store.agentEditor.metadataProvenance(\'title\')"' in modal
@ -203,7 +230,7 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert 'aria-label="Discard current edit"' in modal
assert 'aria-label="Accept current edit"' in modal
assert ':readonly="!$store.agentEditor.isPromptEditing' not in modal
assert ".prompt-pane textarea:focus-visible { outline-offset:-2px; }" in modal
assert ".prompt-ace { flex:1; min-height:0;" in modal
store_source = STORE.read_text(encoding="utf-8")
assert "cannot be recovered" in store_source
assert "deletionImpactHtml" not in store_source
@ -213,34 +240,39 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
switcher_mixin = SWITCHER_MIXIN.read_text(encoding="utf-8")
assert "avatar_url" in switcher_mixin
assert "BUILT_IN_AGENT_COLORS" in switcher_mixin
assert '!["_example", "default"].includes(profile.id)' in switcher_mixin
assert 'activeKey !== "default"' in switcher_mixin
assert "customized: !!profile.has_user_overrides" not in switcher_mixin
assert 'x-for="profile in $store.agentEditor.visibleProfiles"' in modal
assert "$store.agentEditor.activeProfile().id !== 'default'" in modal
assert 'name="palette"' in modal and 'name="add_photo_alternate"' in modal
assert ".easy-tool-summary" not in modal
easy_tool_list_style = re.search(
r"\.easy-tool-list\s*\{([^}]*)\}", modal
assert ".capability-accordion .policy-items { max-height:none; overflow:visible;" in modal
assert ".capability-policy-group + .capability-policy-group { border-top:1px solid var(--color-border);" in modal
assert "font-weight:500" in re.search(
r"\.capability-accordion summary\s*\{([^}]*)\}", modal
).group(1)
assert "max-height" not in easy_tool_list_style
assert "overflow-y" not in easy_tool_list_style
assert "grid-template-columns:minmax(0,1fr) 2.5rem minmax(0,1fr)" in modal
assert ".policy-lists .policy-transfer-actions { flex-direction:row; }" in modal
assert "easyToolsOpen" not in store_source
assert "easySkills" not in store_source
assert "get toolMode" not in store_source
assert "get easyTools" not in store_source
assert "firstSentence" not in store_source
assert ".capability-accordion[open] summary x-icon { transform:rotate(180deg); }" in modal
assert "toolCategory" not in store_source
assert "selectedAllowed" not in store_source and "selectedBlocked" not in store_source
assert "moveAllVisible" not in store_source and "confirmBulkMove" not in store_source
assert "setPolicyItemState" in store_source and "collapsePolicy" in store_source
assert '.agent-editor [aria-invalid="true"]' not in modal
assert "color-scheme:dark" not in modal
assert "#fff 82%" not in modal
assert ".agent-manager-name strong,.agent-manager-copy p { overflow-wrap:anywhere; }" in modal
assert ".field-error { display:block; color:var(--color-text)" in modal
assert ".prompt-pane { min-width:0; min-height:0;" in modal
assert ".prompt-pane" not in modal
assert 'callJsonApi("/plugins/_agent_editor/agent_editor"' in switcher_mixin
assert "profile.enabled !== false" in switcher_mixin
assert 'x-show="!$store.modelConfig.agentProfilesLoading"' in switcher
assert "@keydown.ctrl.s.prevent" in modal
assert "@media (max-width: 760px)" in modal
assert modal.count('<label class="policy-item"><input type="checkbox"') == 4
assert '<div class="policy-item"><input type="checkbox"' not in modal
assert "tri-state-item" not in modal
assert "policy-state-legend" not in modal
assert "policy-item-state" not in modal
assert modal.count("Default (${$store.agentEditor.policyDefault") == 6
assert "border-radius:var(--border-radius-sm)" in modal
assert "cyclePolicyItem" not in store_source and "policyAriaChecked" not in store_source
assert ".agent-manager-card { grid-template-columns:3rem minmax(0,1fr) auto; align-items:start; }" in modal
assert ".agent-manager-actions { grid-column:auto; align-self:stretch; display:grid;" in modal
assert ".agent-manager-actions .agent-profile-availability { grid-column:1/-1; }" in modal
@ -309,12 +341,35 @@ const chatsStore = {
};
const modelConfigStore = {
loadAgentProfiles: async force => calls.push({ endpoint: "loadAgentProfiles", payload: force }),
openPresetEditor: async preset => calls.push({ endpoint: "openPresetEditor", payload: preset }),
selectAgentProfile: async (contextId, profileId) => {
calls.push({ endpoint: "selectAgentProfile", payload: { contextId, profileId } });
return true;
},
getAgentProfileVisual: (_id, label) => ({ color: "#123456", url: "", initials: label?.[0] || "A" }),
};
const aceState = { change: null, find: null, destroyed: false, value: "" };
const aceContainer = { textContent: "" };
const aceSession = {
setMode: value => { aceState.mode = value; },
setUseWrapMode: value => { aceState.wrap = value; },
on: (name, callback) => { if (name === "change") aceState.change = callback; },
off: (name, callback) => { if (name === "change" && aceState.change === callback) aceState.change = null; },
};
const aceEditor = {
container: aceContainer,
session: aceSession,
textInput: { getElement: () => ({ setAttribute: (key, value) => { aceState[key] = value; } }) },
setTheme: value => { aceState.theme = value; },
setOptions: value => { aceState.options = value; },
setValue: value => { aceState.value = value; aceState.change?.(); },
getValue: () => aceState.value,
find: (query, options) => { aceState.find = { query, options }; },
focus: () => { aceState.focused = true; },
resize: () => { aceState.resized = true; },
destroy: () => { aceState.destroyed = true; },
};
globalThis.ace = { edit: container => { aceState.container = container; return aceEditor; } };
globalThis.window = globalThis;
globalThis.document = {
dispatchEvent: (event) => calls.push({ endpoint: "event", payload: event.type }),
@ -350,13 +405,14 @@ store.state = {
{ filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source_chain: ["Framework", "Researcher"], state: "Inherited", has_override: false },
],
model_preset: { has_override: false },
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
};
store.makeDraft(true);
if (await store.previewPlan()) throw new Error("invalid plan unexpectedly succeeded");
if (store.planStatus !== "blocked" || store.error || store.validationIssues().length !== 2) throw new Error("blocked plan state mismatch");
if (store.fieldIssue("name")?.message !== "Agent name is required.") throw new Error("inline name issue missing");
if (store.fieldIssue("instructions")?.field !== "agent-editor-instructions") throw new Error("Easy validation targeted the wrong input");
await store.save();
if (store.error) throw new Error("validation leaked into dismissible error banner");
store.state.profile.id = "new-agent";
@ -365,7 +421,8 @@ store.state.profile.metadata.title = { inherited_source: "agents/new-agent/agent
if (store.metadataProvenance("title") !== "") throw new Error("new profile showed misleading provenance");
store.draft.creating = false;
if (store.metadataProvenance("title") !== "Using the default") throw new Error("default provenance mismatch");
store.profiles = [{ id: "researcher", title: "Researcher" }];
store.profiles = [{ id: "researcher", title: "Researcher" }, { id: "default", title: "Default" }];
if (store.visibleProfiles.length !== 1 || store.visibleProfiles[0].id !== "researcher") throw new Error("Default profile remained selectable in Agent Editor");
store.state.profile.metadata.title = { inherited_source: "agents/researcher/agent.yaml" };
if (store.metadataProvenance("title") !== "Inherited from Researcher") throw new Error("inherited provenance mismatch");
store.state.profile.metadata.title.has_override = true;
@ -378,35 +435,50 @@ if (store.promptCustomizationPath() !== "usr/agents/researcher/prompts/agent.sys
store.projectName = "demo";
if (store.promptCustomizationPath() !== "usr/projects/demo/.a0proj/agents/researcher/prompts/agent.system.main.specifics.md") throw new Error("project prompt customization path mismatch");
store.projectName = "";
store.state.model_preset.effective = "Current";
store.state.model_presets = [{ name: "Codex" }];
store.draft.modelPreset = "Codex";
if (store.buildPatch().model_preset?.name !== "Codex") throw new Error("Easy model preset was not saved");
loadHandler = () => ({ ok: true, state: { model_presets: [{ name: "Codex" }] } });
await store.openPresetManager();
loadHandler = null;
if (!calls.some(call => call.endpoint === "openPresetEditor" && call.payload === "Codex")) throw new Error("Easy preset editor action did not reuse Model Configuration");
store.draft.modelPreset = "";
store.state.tools.catalog = [
{ id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
{ id: "local:gone", name: "gone", label: "Gone", origin: "Unavailable", available: false },
{ id: "mcp:docs:read", name: "read", label: "Docs read", origin: "MCP", available: true },
];
store.draft.toolPolicy = { mode: "custom", default: "allow", allowed: [], blocked: ["local:shell"] };
store.draft.toolPolicy = { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] };
store.initialDraft.toolPolicy = clone(store.draft.toolPolicy);
if (store.standardToolCatalog.length !== 1 || store.mcpCatalog.length !== 1 || store.toolCatalog.length !== 2) throw new Error("Easy tool/MCP grouping mismatch");
if (store.filteredTools("tool").length !== 2 || store.filteredTools("mcp").length !== 1) throw new Error("Advanced retained catalog grouping mismatch");
if (store.policyItemState("tool", "local:shell") !== "default") throw new Error("initial segmented state mismatch");
store.setPolicyItem("tool", "local:shell", "allow");
if (store.policyItemState("tool", "local:shell") !== "allow" || !store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("On selection failed");
store.setPolicyItem("tool", "local:shell", "block");
if (store.policyItemState("tool", "local:shell") !== "block" || !store.draft.toolPolicy.blocked.includes("local:shell")) throw new Error("Off selection failed");
if (JSON.stringify(store.skillWarnings({ allowed_tools: ["shell"] })) !== JSON.stringify(["shell"])) throw new Error("live skill warning missing");
if (store.toolCatalog.length !== 1 || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("Easy custom tool state mismatch");
await store.moveAllVisibleTools(true);
if (confirmations.at(-1)?.title !== "Allow 1 shown tool?" || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("filtered bulk confirmation mismatch");
confirmations.length = 0;
store.selectedAllowedTools = ["local:shell"];
store.useStandardTools();
if (store.draft.toolPolicy.mode !== "inherit" || !store.isToolAllowed(store.state.tools.catalog[0]) || store.filteredTools(true).length !== 1 || store.selectedAllowedTools.length) throw new Error("standard tool state mismatch");
store.setEasyToolAllowed("local:shell", false);
if (store.draft.toolPolicy.mode !== "custom" || store.draft.toolPolicy.default !== "allow" || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("Easy uncheck did not block tool");
store.setEasyToolAllowed("local:shell", true);
if (store.draft.toolPolicy.mode !== "inherit" || !store.isToolAllowed(store.state.tools.catalog[0]) || store.draft.toolPolicy.blocked.length) throw new Error("Easy recheck did not restore standard access");
store.draft.toolPolicy = { mode: "custom", default: "block", allowed: [], blocked: [] };
store.setEasyToolAllowed("local:shell", true);
if (!store.isToolAllowed(store.state.tools.catalog[0]) || !store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("Easy check ignored block-by-default policy");
store.setEasyToolAllowed("local:shell", false);
if (store.isToolAllowed(store.state.tools.catalog[0]) || store.draft.toolPolicy.allowed.length) throw new Error("Easy uncheck ignored block-by-default policy");
store.useStandardTools();
store.chooseTools();
if (store.draft.toolPolicy.mode !== "custom" || store.draft.toolPolicy.default !== "allow" || store.section !== "3") throw new Error("custom tool editor did not open");
store.useStandardTools();
store.state.tools.effective_policy = { mode: "inherit", default: "block", allowed: [], blocked: ["local:shell"] };
store.chooseTools();
if (store.draft.toolPolicy.default !== "allow" || store.draft.toolPolicy.blocked.length) throw new Error("inactive inherited exceptions leaked into custom policy");
store.setPolicyItem("tool", "local:shell", "default");
if (store.draft.toolPolicy.mode !== "inherit" || store.policyItemState("tool", "local:shell") !== "default") throw new Error("segmented undo did not collapse to inherit");
store.setPolicyDefault("tool", "block");
store.setPolicyItem("tool", "local:shell", "allow");
store.setPolicyDefault("tool", "allow");
if (!store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("explicit On was lost when the default changed");
store.setPolicyItem("tool", "local:shell", "block");
store.setPolicyDefault("tool", "block");
if (!store.draft.toolPolicy.blocked.includes("local:shell")) throw new Error("explicit Off was lost when the default changed");
store.setPolicyItem("tool", "local:shell", "default");
store.setPolicyDefault("tool", "allow");
if (store.draft.toolPolicy.mode !== "inherit") throw new Error("default undo did not collapse to inherit");
store.setPolicyDefault("mcp", "block");
if (store.policyDefault("tool") !== "allow" || store.policyDefault("mcp") !== "block") throw new Error("tool and MCP defaults were not independent");
if (!store.isToolAllowed(store.state.tools.catalog[0]) || store.isToolAllowed(store.state.tools.catalog[2])) throw new Error("MCP default affected the wrong catalog group");
store.setPolicyItem("mcp", "mcp:docs:read", "allow");
store.setPolicyDefault("mcp", "allow");
if (!store.draft.toolPolicy.allowed.includes("mcp:docs:read")) throw new Error("explicit MCP On was lost when its default changed");
store.setPolicyItem("mcp", "mcp:docs:read", "default");
if (store.draft.toolPolicy.mode !== "inherit") throw new Error("MCP default undo did not collapse to inherit");
store.projectName = "demo";
store.intent = { ...store.intent, projectName: "demo" };
if (!store.currentChatUsesScope() || !store.isProfileActive("researcher")) throw new Error("active project profile state mismatch");
@ -468,26 +540,29 @@ confirmResult = false;
store.projectName = "other";
if (store.currentChatUsesScope() || store.isProfileActive("default")) throw new Error("foreign project profile appeared active");
store.projectName = "demo";
store.state.tools.effective_policy = { mode: "custom", default: "allow", allowed: [], blocked: ["local:shell"] };
store.useStandardTools();
store.state.tools.effective_policy = { mode: "custom", default: "allow", mcp_default: "allow", allowed: [], blocked: ["local:shell"] };
if (store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope ignored inherited tool restriction");
store.setEasyToolAllowed("local:shell", true);
store.setPolicyItem("tool", "local:shell", "default");
if (store.draft.toolPolicy.mode !== "custom" || !store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not customize inherited policy");
store.setEasyToolAllowed("local:shell", false);
store.setPolicyItem("tool", "local:shell", "allow");
if (!store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("project scope did not pin explicit On");
store.setPolicyItem("tool", "local:shell", "block");
if (store.draft.toolPolicy.mode !== "inherit" || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not restore inherited policy");
store.projectName = "";
store.state.tools.effective_policy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
store.state.tools.effective_policy = { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] };
store.state.skills.catalog = [
{ name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research sources", available: true, tags: [], allowed_tools: [] },
{ name: "Gone", path: "skills/gone/SKILL.md", origin: "Unavailable", description: "Missing skill", available: false, tags: [], allowed_tools: [] },
];
store.draft.skillPolicy = { mode: "custom", default: "allow", allowed: [], blocked: ["Research"] };
if (store.skillCatalog.length !== 1 || store.filteredSkills(false).length !== 1 || store.filteredSkills(true).length !== 1) throw new Error("custom skill catalog mismatch");
store.selectedBlockedSkills = ["Research"];
store.useStandardSkills();
if (store.draft.skillPolicy.mode !== "inherit" || store.filteredSkills(true).length !== 1 || store.filteredSkills(false).length || store.selectedBlockedSkills.length) throw new Error("standard skill summary mismatch");
store.chooseSkills();
if (store.draft.skillPolicy.mode !== "custom" || store.draft.skillPolicy.default !== "allow") throw new Error("custom skill editor did not open");
store.draft.skillPolicy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
store.initialDraft.skillPolicy = clone(store.draft.skillPolicy);
if (store.skillCatalog.length !== 1 || store.filteredSkills().length !== 2) throw new Error("Easy/Advanced skill catalog mismatch");
store.setPolicyItem("skill", "Research", "allow");
if (!store.draft.skillPolicy.allowed.includes("Research")) throw new Error("skill On selection failed");
store.setPolicyItem("skill", "Research", "block");
if (!store.draft.skillPolicy.blocked.includes("Research")) throw new Error("skill Off selection failed");
store.setPolicyItem("skill", "Research", "default");
if (store.draft.skillPolicy.mode !== "inherit") throw new Error("skill sparse undo did not collapse");
store.draft.title = "Preserved Agent";
store.onNameInput();
store.instructions.value = "Preserved instructions";
@ -499,6 +574,7 @@ store.mode = "advanced";
store.instructions.value = "";
const callsBeforeInvalidAdvancedCreate = calls.length;
if (!store.fieldIssue("instructions")) throw new Error("Advanced create accepted empty instructions");
if (store.fieldIssue("instructions")?.field !== "agent-editor-prompt-ace") throw new Error("Advanced validation targeted the wrong editor");
await store.save();
if (calls.length !== callsBeforeInvalidAdvancedCreate) throw new Error("Advanced create submitted empty instructions");
store.instructions.value = "Preserved instructions";
@ -525,7 +601,7 @@ store.state = {
],
model_preset: { has_override: false, effective: "Default" },
model_presets: [],
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
{ id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
{ id: "local:old", name: "old", label: "Old", origin: "Old scope", available: true },
] },
@ -537,6 +613,25 @@ store.state = {
store.view = "editor";
store.intent = { ...store.intent, view: "create", projectName: "" };
store.makeDraft(true);
store.root = {
querySelector: selector => selector === "#agent-editor-prompt-ace" ? aceContainer : null,
contains: node => node === aceContainer,
closest: () => null,
};
store.mode = "advanced";
store.section = "2";
const draftBeforeAce = JSON.stringify(store.draft);
store.initPromptEditor();
if (aceState.mode !== "ace/mode/markdown" || aceState.wrap !== true || aceState.options?.showPrintMargin !== false || aceState.options?.useWorker !== false) throw new Error("ACE configuration mismatch");
if (aceState["aria-label"] !== "Prompt Markdown" || JSON.stringify(store.draft) !== draftBeforeAce) throw new Error("ACE initialization created a false edit");
aceState.value = "Edited in ACE";
aceState.change();
if (store.instructions.value !== "Edited in ACE" || !store.promptEditPending(store.instructions)) throw new Error("ACE change did not update the prompt draft");
store.selectPrompt("agent.system.main.communication.md");
if (aceState.value !== "Old comm" || store.instructions.value !== "Edited in ACE") throw new Error("ACE file switch lost a draft");
store.selectPrompt("agent.system.main.specifics.md");
store.destroyPromptEditor();
if (!aceState.destroyed || aceState.change) throw new Error("ACE instance was not destroyed cleanly");
store.draft.title = "Scoped Agent";
store.onNameInput();
store.draft.description = "Scoped description";
@ -546,10 +641,9 @@ store.markPromptSet("agent.system.main.specifics.md");
store.draft.prompts["agent.system.main.communication.md"].value = "Authored communication";
store.acceptPromptEdit("agent.system.main.communication.md");
store.chooseAvatarColor("#ABCDEF");
store.setEasyToolAllowed("local:shell", false);
store.setPolicyItem("tool", "local:shell", "block");
store.setPolicyDefault("tool", "block");
store.chooseSkills();
store.moveSkills(["Research"], false);
store.setPolicyItem("skill", "Research", "block");
const projectState = {
profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: { effective: { kind: "color", value: "#222222" } } } },
prompts: [
@ -558,7 +652,7 @@ const projectState = {
],
model_preset: { has_override: false, effective: "Default" },
model_presets: [],
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
{ id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
{ id: "local:new", name: "new", label: "New", origin: "Project", available: true },
] },
@ -569,7 +663,7 @@ const projectState = {
};
loadHandler = () => ({ ok: true, state: projectState });
store.mode = "advanced";
store.section = "5";
store.section = "6";
store.projectName = "demo";
store.intent = { ...store.intent, projectName: "" };
calls.length = 0;
@ -577,7 +671,7 @@ await store.onScopeChanged();
if (store.state !== projectState || store.draft.title !== "Scoped Agent" || store.draft.profileId !== "scoped-agent") throw new Error("create scope rebase lost identity");
if (store.draft.description !== "Scoped description" || store.draft.context !== "Use for scoped work") throw new Error("create scope rebase lost authored metadata");
if (store.instructions.value !== "Authored instructions" || store.instructions.source !== "project-source") throw new Error("create scope rebase kept stale prompt provenance");
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");
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 tool policy");
if (store.isSkillAllowed(projectState.skills.catalog[0]) || !store.isSkillAllowed(projectState.skills.catalog[1])) throw new Error("create scope rebase lost explicit skill decision");
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");
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");
@ -602,14 +696,13 @@ store.onPromptInput(communication.filename);
store.acceptPromptEdit(communication.filename);
if (store.promptEditPending(communication)) throw new Error("prompt edit was not accepted");
if (store.promptDisplayState(communication) !== "Customized by you") throw new Error("customized state missing");
if (store.promptSourceChain(communication) !== "Customized by you") throw new Error("customized provenance missing");
store.resetPrompt(communication.filename);
if (store.promptEditPending(communication) || store.promptDisplayState(communication) !== "Will use the default") throw new Error("reset state mismatch");
const draftBeforeModes = JSON.stringify(store.draft);
store.setMode("advanced", "2");
store.setMode("easy");
if (store.section !== "2" || JSON.stringify(store.draft) !== draftBeforeModes) throw new Error("mode switch lost draft");
store.setMode("advanced", "5");
store.setMode("advanced", "6");
await Promise.resolve();
await Promise.resolve();
if (store.planStatus !== "ready" || calls.at(-1).payload.action !== "plan") throw new Error("review plan was not computed on entry");
@ -634,7 +727,7 @@ if (calls.length || !store.error.includes("Save or discard")) throw new Error("d
store.initialDraft = clone(store.draft);
store.error = "";
await store.planRemoval(true);
if (!store.pendingMutation?.destructive || store.section !== "5" || store.planStatus !== "ready") throw new Error("removal plan was replaced");
if (!store.pendingMutation?.destructive || store.section !== "6" || store.planStatus !== "ready") throw new Error("removal plan was replaced");
if (calls.at(-1).payload.action !== "plan_remove_changes") throw new Error("removal plan request missing");
if (calls.at(-1).payload.project_name !== "demo") throw new Error("removal request lost selected scope");
const callsBeforePendingSave = calls.length;
@ -704,9 +797,13 @@ const store = { ...switcherState, ...switcherMethods };
const older = store.loadAgentProfiles(true);
const newer = store.loadAgentProfiles(true);
if (pending.length !== 2 || !store.agentProfilesLoading) throw new Error("overlapping profile loads did not start");
pending[1]({ profiles: [{ id: "new", title: "New", enabled: true }] });
pending[1]({ profiles: [
{ id: "default", title: "Default", enabled: true },
{ id: "new", title: "New", enabled: true },
] });
await newer;
if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("newest profile load did not settle");
if (store.agentProfiles.length !== 1 || store.getAgentProfileList("default", "Default").some(profile => profile.key === "default")) throw new Error("Default profile remained selectable in the chat popover");
pending[0]({ profiles: [{ id: "old", title: "Old", enabled: true }] });
await older;
if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("stale profile load replaced newer state");

View file

@ -53,10 +53,11 @@ def _prompt_paths(root: Path):
return get_paths
def _custom_policy(*, default: str, allowed=(), blocked=()):
def _custom_policy(*, default: str, mcp_default: str = "allow", allowed=(), blocked=()):
return {
"mode": "custom",
"default": default,
"mcp_default": mcp_default,
"allowed": list(allowed),
"blocked": list(blocked),
}
@ -157,6 +158,25 @@ def test_required_response_survives_default_block(monkeypatch, tmp_path: Path) -
assert tool_policy.get_tool_catalog(agent) == []
def test_tool_and_mcp_defaults_are_independent(monkeypatch, tmp_path: Path) -> None:
agent = _Agent(tmp_path)
monkeypatch.setattr(
tool_policy,
"get_policy",
lambda _agent: _custom_policy(
default="block",
mcp_default="allow",
allowed=["local:pinned"],
blocked=["mcp:docs:delete"],
),
)
assert tool_policy.resolve_tool(agent, "shell", canonical_id="local:shell").allowed is False
assert tool_policy.resolve_tool(agent, "read", canonical_id="mcp:docs:read").allowed is True
assert tool_policy.resolve_tool(agent, "pinned", canonical_id="local:pinned").allowed is True
assert tool_policy.resolve_tool(agent, "delete", canonical_id="mcp:docs:delete").allowed is False
def test_catalog_comes_from_executable_tools_not_prompt_names(
monkeypatch, tmp_path: Path
) -> None: