Skip to content

fix(ui): enable Save when editing agents without existing config entries#39306

Open
SergioChan wants to merge 2 commits intoopenclaw:mainfrom
SergioChan:fix/agents-save-enable
Open

fix(ui): enable Save when editing agents without existing config entries#39306
SergioChan wants to merge 2 commits intoopenclaw:mainfrom
SergioChan:fix/agents-save-enable

Conversation

@SergioChan
Copy link
Copy Markdown

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: Agent-page edits (tools/skills/model toggles) were ignored when the selected agent had no explicit agents.list[] entry in config, so Save stayed disabled.
  • Why it matters: Users can toggle controls in the Agents UI but cannot persist changes for inherited/default-only agents.
  • What changed: Added ensureAgentConfigIndex() in ui/src/ui/app-render.ts and routed agent edit handlers through it, auto-creating { id: <agentId> } in agents.list before writing overrides.
  • What did NOT change (scope boundary): No backend config semantics changed; only UI-side form mutation/index resolution logic.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Agents page Save button now becomes enabled after editing tools/skills/model settings even when that agent did not previously exist in agents.list.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: Ubuntu (WSL2)
  • Runtime/container: Node + pnpm workspace
  • Model/provider: N/A
  • Integration/channel (if any): Gateway UI
  • Relevant config (redacted): Agent present in runtime list but missing explicit agents.list entry

Steps

  1. Open Agents page for an agent that inherits defaults and has no explicit agents.list item.
  2. Toggle a setting in tools/skills/model controls.
  3. Observe Save button state.

Expected

  • Save becomes enabled and persists the change.

Actual

  • Before fix: toggle writes were dropped, Save stayed disabled.
  • After fix: agent entry is created in form state and Save enables.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: Ran pnpm -C /home/chen/.openclaw/workspace/tmp-openclaw exec vitest run ui/src/ui/controllers/agents.test.ts after patch.
  • Edge cases checked: Handlers now safely resolve missing list entries before writing model/tools/skills overrides.
  • What you did not verify: Full interactive browser/manual click-through of gateway UI in this run.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Revert commit 6d7007bd2.
  • Files/config to restore: ui/src/ui/app-render.ts.
  • Known bad symptoms reviewers should watch for: Unexpected duplicate agents.list entries (not expected due id-based lookup).

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: Auto-creating minimal agent entries could unintentionally persist only id + one override key.
    • Mitigation: This mirrors intended override behavior; writes remain scoped to the specific edited path.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR fixes a bug where the Save button stayed disabled when editing agents that had no explicit agents.list entry, by introducing ensureAgentConfigIndex() — a helper that lazily creates a minimal { id: agentId } config entry before any write operation. The refactor also removes a substantial amount of duplicated index-lookup boilerplate from each handler.

Key points:

  • The core fix is sound: routing all write handlers through ensureAgentConfigIndex correctly enables Save after any genuine edit on an inherited/default-only agent.
  • Logic issue: In onModelChange, onModelFallbacksChange, and onToolsProfileChange, ensureAgentConfigIndex is invoked before the handlers' own early-return guards for clearing/no-op paths. A user clearing a model (or tools profile) on an agent with no existing config entry will unintentionally create a bare { id: agentId } entry, mark the form dirty, and enable Save — even though no meaningful change was made.
  • The if (index == null) guards added after every ensureAgentConfigIndex call are unreachable dead code, since the helper always returns a non-negative integer.

Confidence Score: 3/5

  • Safe to merge with minor caveats — the fix works for the primary "set" case but has a side-effect on clear/remove paths for unconfigured agents.
  • The main bug (Save disabled for agents without a config entry) is correctly fixed. However, the unconditional placement of ensureAgentConfigIndex before early-return guards in onModelChange, onModelFallbacksChange, and onToolsProfileChange introduces a secondary issue: clearing a value on an unconfigured agent creates a spurious bare entry and enables Save unnecessarily. This is a real behavioral regression on those paths, though it only affects the less-common "clear" flow rather than the "set" flow the PR primarily targets.
  • Pay attention to the onModelChange, onModelFallbacksChange, and onToolsProfileChange handlers in ui/src/ui/app-render.ts.

Last reviewed commit: 6d7007b

Comment on lines 776 to 784
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,
);
if (index < 0) {
const index = ensureAgentConfigIndex(agentId);
if (index == null) {
return;
}
const basePath = ["agents", "list", index, "model"];
if (!modelId) {
removeConfigFormValue(state, basePath);
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare entry created when clearing model for unconfigured agent

ensureAgentConfigIndex(agentId) is called unconditionally at the top of onModelChange, before the early-return guard for a falsy modelId. If a user triggers a "clear model" action for an agent that has no existing agents.list entry, the function will:

  1. Create a bare { id: agentId } entry in the config via updateConfigFormValue.
  2. Call removeConfigFormValue on the newly created entry's model path — which is a no-op since no model key exists.

The net result is an unintended { id: agentId } entry persisted in state.configForm, configFormDirty is set to true, and the Save button becomes enabled even though the user made no meaningful change. The same pattern affects onModelFallbacksChange (when normalized.length === 0 and there is no primary) and onToolsProfileChange (when profile is falsy and clearAllow is false).

Move ensureAgentConfigIndex to after the early-return checks in these handlers, so it is only invoked when an actual value is being written:

onModelChange: (agentId, modelId) => {
  const basePath = ["agents", "list", 0 /* placeholder */, "model"];
  if (!modelId) {
    // Only try to remove if the entry already exists
    const existingIndex = /* find without creating */ ...;
    if (existingIndex >= 0) {
      removeConfigFormValue(state, ["agents", "list", existingIndex, "model"]);
    }
    return;
  }
  const index = ensureAgentConfigIndex(agentId);
  if (index == null) { return; }
  // ... rest of write logic
},

Alternatively, extract the read-only lookup into a separate helper (e.g. findAgentConfigIndex) that does not create entries, and use it for the clear/remove paths.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-render.ts
Line: 776-784

Comment:
**Bare entry created when clearing model for unconfigured agent**

`ensureAgentConfigIndex(agentId)` is called unconditionally at the top of `onModelChange`, **before** the early-return guard for a falsy `modelId`. If a user triggers a "clear model" action for an agent that has no existing `agents.list` entry, the function will:

1. Create a bare `{ id: agentId }` entry in the config via `updateConfigFormValue`.
2. Call `removeConfigFormValue` on the newly created entry's `model` path — which is a no-op since no `model` key exists.

The net result is an unintended `{ id: agentId }` entry persisted in `state.configForm`, `configFormDirty` is set to `true`, and the **Save button becomes enabled even though the user made no meaningful change**. The same pattern affects `onModelFallbacksChange` (when `normalized.length === 0` and there is no primary) and `onToolsProfileChange` (when `profile` is falsy and `clearAllow` is false).

Move `ensureAgentConfigIndex` to after the early-return checks in these handlers, so it is only invoked when an actual value is being written:

```
onModelChange: (agentId, modelId) => {
  const basePath = ["agents", "list", 0 /* placeholder */, "model"];
  if (!modelId) {
    // Only try to remove if the entry already exists
    const existingIndex = /* find without creating */ ...;
    if (existingIndex >= 0) {
      removeConfigFormValue(state, ["agents", "list", existingIndex, "model"]);
    }
    return;
  }
  const index = ensureAgentConfigIndex(agentId);
  if (index == null) { return; }
  // ... rest of write logic
},
```

Alternatively, extract the read-only lookup into a separate helper (e.g. `findAgentConfigIndex`) that does not create entries, and use it for the clear/remove paths.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +170 to +190
const ensureAgentConfigIndex = (agentId: string): number | null => {
const current =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null) ?? {};
const agents =
current && typeof current === "object" && !Array.isArray(current)
? ((current as { agents?: unknown }).agents as { list?: unknown[] } | undefined)
: undefined;
const list = Array.isArray(agents?.list) ? agents.list : [];
const existingIndex = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (existingIndex >= 0) {
return existingIndex;
}
updateConfigFormValue(state, ["agents", "list"], [...list, { id: agentId }]);
return list.length;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null return from ensureAgentConfigIndex is unreachable

The function always returns a non-negative integer — either existingIndex (guaranteed >= 0) or list.length (guaranteed >= 0). It never returns null. All six if (index == null) { return; } guards in the handlers are therefore dead code.

This is harmless defensiveness, but it makes the code slightly misleading: readers may wonder under what circumstances the function could return null. Consider either changing the return type to number (removing the | null) to make the contract explicit, or documenting why the null branch is kept as a future-proof safety valve.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-render.ts
Line: 170-190

Comment:
**`null` return from `ensureAgentConfigIndex` is unreachable**

The function always returns a non-negative integer — either `existingIndex` (guaranteed `>= 0`) or `list.length` (guaranteed `>= 0`). It never returns `null`. All six `if (index == null) { return; }` guards in the handlers are therefore dead code.

This is harmless defensiveness, but it makes the code slightly misleading: readers may wonder under what circumstances the function could return `null`. Consider either changing the return type to `number` (removing the `| null`) to make the contract explicit, or documenting why the null branch is kept as a future-proof safety valve.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d7007bd2b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (existingIndex >= 0) {
return existingIndex;
}
updateConfigFormValue(state, ["agents", "list"], [...list, { id: agentId }]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve default agent when creating first agents.list entry

When a config has no agents.list, this write creates agents.list = [{ id: agentId }] for whichever agent is edited first. That silently changes the gateway default agent on save, because resolveDefaultAgentId picks the first list entry when no entry has default: true (src/agents/agent-scope.ts, resolveDefaultAgentId). In practice, editing a non-default agent from the Agents UI can reroute later session/command flows that rely on implicit default-agent resolution.

Useful? React with 👍 / 👎.

@SergioChan
Copy link
Copy Markdown
Author

Thanks for the careful review — great catch on the clear/no-op paths. I confirmed the concern: calling ensureAgentConfigIndex() before early-return guards can create a spurious { id } entry for unconfigured agents when clearing values. I’m preparing a small follow-up commit that keeps guard checks before index creation on those handlers so we only materialize config entries when an actual persisted change exists.

@SergioChan
Copy link
Copy Markdown
Author

Pushed a follow-up commit to address this (99afc76).

What changed:

  • Added findAgentConfigIndex() and only materialize a new agents.list entry when a persisted change is actually needed.
  • onModelChange: clearing model now no-ops if the agent has no explicit config entry.
  • onModelFallbacksChange: empty fallbacks now no-op for unconfigured agents.
  • onToolsProfileChange: clear/no-op paths avoid creating a bare { id } entry unless required.

Validation run locally:

  • pnpm exec vitest run ui/src/ui/controllers/agents.test.ts

Thanks again for spotting the edge case.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99afc7602a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +700 to 701
if (!profile && !clearAllow) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow clearing an agent tool profile override

The new early return if (!profile && !clearAllow) return; makes the "Inherit" action a no-op, because that button calls onToolsProfileChange(agentId, null, false); when an agent already has tools.profile set, users can no longer clear that override and fall back to global/default tool policy. Before this change, existing entries would reach removeConfigFormValue(..."profile"), so this is a functional regression that blocks a normal config workflow.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cannot save agent changes

1 participant