From 49261b0d822e126b7105e20615ab0ae20e818796 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 02:11:35 +0000 Subject: [PATCH] fix: auto-create inherited agent override entries Landed from contributor PR #39326 by @dunamismax. Co-authored-by: dunamismax --- CHANGELOG.md | 1 + ui/src/ui/app-render.ts | 172 ++++++++++----------------- ui/src/ui/controllers/config.test.ts | 85 +++++++++++++ ui/src/ui/controllers/config.ts | 38 ++++++ 4 files changed, 187 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89efe3ff73d..0a9b80db182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 6bd61c2f226..6006496c1ff 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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 | 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 diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 54d04bb1ea7..826030f884e 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -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({}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 9ca669aa592..c0daeb654dd 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -217,3 +217,41 @@ export function removeConfigFormValue(state: ConfigState, path: Array | 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 | 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; +}