mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user