fix: auto-create inherited agent override entries

Landed from contributor PR #39326 by @dunamismax.

Co-authored-by: dunamismax <dunamismax@tutamail.com>
This commit is contained in:
Peter Steinberger
2026-03-08 02:11:35 +00:00
parent 1e05f14f3a
commit 49261b0d82
4 changed files with 187 additions and 109 deletions

View File

@@ -336,6 +336,7 @@ Docs: https://docs.openclaw.ai
- Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
- Agents/codex-cli sandbox defaults: switch the built-in Codex backend from `read-only` to `workspace-write` so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.
- Gateway/health-monitor restart reason labeling: report `disconnected` instead of `stuck` for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.
- Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
## 2026.3.2

View File

@@ -13,6 +13,8 @@ import { loadChannels } from "./controllers/channels.ts";
import { loadChatHistory } from "./controllers/chat.ts";
import {
applyConfig,
ensureAgentConfigEntry,
findAgentConfigEntryIndex,
loadConfig,
runUpdate,
saveConfig,
@@ -66,7 +68,13 @@ import {
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { resolveConfiguredCronModelSuggestions, sortLocaleStrings } from "./views/agents-utils.ts";
import {
resolveAgentConfig,
resolveConfiguredCronModelSuggestions,
resolveEffectiveModelFallbacks,
resolveModelPrimary,
sortLocaleStrings,
} from "./views/agents-utils.ts";
import { renderAgents } from "./views/agents.ts";
import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts";
@@ -166,6 +174,11 @@ export function renderApp(state: AppViewState) {
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
const getCurrentConfigValue = () =>
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const findAgentIndex = (agentId: string) =>
findAgentConfigEntryIndex(getCurrentConfigValue(), agentId);
const ensureAgentIndex = (agentId: string) => ensureAgentConfigEntry(state, agentId);
const cronAgentSuggestions = sortLocaleStrings(
new Set(
[
@@ -663,20 +676,8 @@ export function renderApp(state: AppViewState) {
void saveAgentFile(state, resolvedAgentId, name, content);
},
onToolsProfileChange: (agentId, profile, clearAllow) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index =
profile || clearAllow ? ensureAgentIndex(agentId) : findAgentIndex(agentId);
if (index < 0) {
return;
}
@@ -691,20 +692,10 @@ export function renderApp(state: AppViewState) {
}
},
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index =
alsoAllow.length > 0 || deny.length > 0
? ensureAgentIndex(agentId)
: findAgentIndex(agentId);
if (index < 0) {
return;
}
@@ -731,24 +722,15 @@ export function renderApp(state: AppViewState) {
}
},
onAgentSkillToggle: (agentId, skillName, enabled) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index = ensureAgentIndex(agentId);
if (index < 0) {
return;
}
const entry = list[index] as { skills?: unknown };
const list = (getCurrentConfigValue() as { agents?: { list?: unknown[] } } | null)
?.agents?.list;
const entry = Array.isArray(list)
? (list[index] as { skills?: unknown })
: undefined;
const normalizedSkill = skillName.trim();
if (!normalizedSkill) {
return;
@@ -756,7 +738,7 @@ export function renderApp(state: AppViewState) {
const allSkills =
state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ??
[];
const existing = Array.isArray(entry.skills)
const existing = Array.isArray(entry?.skills)
? entry.skills.map((name) => String(name).trim()).filter(Boolean)
: undefined;
const base = existing ?? allSkills;
@@ -769,69 +751,34 @@ export function renderApp(state: AppViewState) {
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
},
onAgentSkillsClear: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index = findAgentIndex(agentId);
if (index < 0) {
return;
}
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
},
onAgentSkillsDisableAll: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index = ensureAgentIndex(agentId);
if (index < 0) {
return;
}
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
},
onModelChange: (agentId, modelId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
const index = modelId ? ensureAgentIndex(agentId) : findAgentIndex(agentId);
if (index < 0) {
return;
}
const list = (getCurrentConfigValue() as { agents?: { list?: unknown[] } } | null)
?.agents?.list;
const basePath = ["agents", "list", index, "model"];
if (!modelId) {
removeConfigFormValue(state, basePath);
return;
}
const entry = list[index] as { model?: unknown };
const entry = Array.isArray(list)
? (list[index] as { model?: unknown })
: undefined;
const existing = entry?.model;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
@@ -845,27 +792,34 @@ export function renderApp(state: AppViewState) {
}
},
onModelFallbacksChange: (agentId, fallbacks) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const currentConfig = getCurrentConfigValue();
const resolvedConfig = resolveAgentConfig(currentConfig, agentId);
const effectivePrimary =
resolveModelPrimary(resolvedConfig.entry?.model) ??
resolveModelPrimary(resolvedConfig.defaults?.model);
const effectiveFallbacks = resolveEffectiveModelFallbacks(
resolvedConfig.entry?.model,
resolvedConfig.defaults?.model,
);
const index =
normalized.length > 0
? effectivePrimary
? ensureAgentIndex(agentId)
: -1
: (effectiveFallbacks?.length ?? 0) > 0 || findAgentIndex(agentId) >= 0
? ensureAgentIndex(agentId)
: -1;
if (index < 0) {
return;
}
const list = (getCurrentConfigValue() as { agents?: { list?: unknown[] } } | null)
?.agents?.list;
const basePath = ["agents", "list", index, "model"];
const entry = list[index] as { model?: unknown };
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const existing = entry.model;
const entry = Array.isArray(list)
? (list[index] as { model?: unknown })
: undefined;
const existing = entry?.model;
const resolvePrimary = () => {
if (typeof existing === "string") {
return existing.trim() || null;
@@ -879,7 +833,7 @@ export function renderApp(state: AppViewState) {
}
return null;
};
const primary = resolvePrimary();
const primary = resolvePrimary() ?? effectivePrimary;
if (normalized.length === 0) {
if (primary) {
updateConfigFormValue(state, basePath, primary);
@@ -888,10 +842,10 @@ export function renderApp(state: AppViewState) {
}
return;
}
const next = primary
? { primary, fallbacks: normalized }
: { fallbacks: normalized };
updateConfigFormValue(state, basePath, next);
if (!primary) {
return;
}
updateConfigFormValue(state, basePath, { primary, fallbacks: normalized });
},
})
: nothing

View File

@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import {
applyConfigSnapshot,
applyConfig,
ensureAgentConfigEntry,
findAgentConfigEntryIndex,
runUpdate,
saveConfig,
updateConfigFormValue,
@@ -146,6 +148,89 @@ describe("updateConfigFormValue", () => {
});
});
describe("agent config helpers", () => {
it("finds explicit agent entries", () => {
expect(
findAgentConfigEntryIndex(
{
agents: {
list: [{ id: "main" }, { id: "assistant" }],
},
},
"assistant",
),
).toBe(1);
});
it("creates an agent override entry when editing an inherited agent", () => {
const state = createState();
state.configSnapshot = {
config: {
agents: {
defaults: { model: "openai/gpt-5" },
},
tools: { profile: "messaging" },
},
valid: true,
issues: [],
raw: "{\n}\n",
};
const index = ensureAgentConfigEntry(state, "main");
expect(index).toBe(0);
expect(state.configFormDirty).toBe(true);
expect(state.configForm).toEqual({
agents: {
defaults: { model: "openai/gpt-5" },
list: [{ id: "main" }],
},
tools: { profile: "messaging" },
});
});
it("reuses the existing agent entry instead of duplicating it", () => {
const state = createState();
state.configSnapshot = {
config: {
agents: {
list: [{ id: "main", model: "openai/gpt-5" }],
},
},
valid: true,
issues: [],
raw: "{\n}\n",
};
const index = ensureAgentConfigEntry(state, "main");
expect(index).toBe(0);
expect(state.configFormDirty).toBe(false);
expect(state.configForm).toBeNull();
});
it("reuses an agent entry that already exists in the pending form state", () => {
const state = createState();
state.configSnapshot = {
config: {},
valid: true,
issues: [],
raw: "{\n}\n",
};
updateConfigFormValue(state, ["agents", "list", 0, "id"], "main");
const index = ensureAgentConfigEntry(state, "main");
expect(index).toBe(0);
expect(state.configForm).toEqual({
agents: {
list: [{ id: "main" }],
},
});
});
});
describe("applyConfig", () => {
it("sends config.apply with raw and session key", async () => {
const request = vi.fn().mockResolvedValue({});

View File

@@ -217,3 +217,41 @@ export function removeConfigFormValue(state: ConfigState, path: Array<string | n
state.configRaw = serializeConfigForm(base);
}
}
export function findAgentConfigEntryIndex(
config: Record<string, unknown> | null,
agentId: string,
): number {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) {
return -1;
}
const list = (config as { agents?: { list?: unknown[] } } | null)?.agents?.list;
if (!Array.isArray(list)) {
return -1;
}
return list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === normalizedAgentId,
);
}
export function ensureAgentConfigEntry(state: ConfigState, agentId: string): number {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) {
return -1;
}
const source =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const existingIndex = findAgentConfigEntryIndex(source, normalizedAgentId);
if (existingIndex >= 0) {
return existingIndex;
}
const list = (source as { agents?: { list?: unknown[] } } | null)?.agents?.list;
const nextIndex = Array.isArray(list) ? list.length : 0;
updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId);
return nextIndex;
}