fix: preserve agents-page selection after config save

Landed from contributor PR #39301 by @MumuTW.

Co-authored-by: MumuTW <clothl47364@gmail.com>
This commit is contained in:
Peter Steinberger 2026-03-08 02:18:32 +00:00
parent 1e3daa6373
commit c0a7c302f3
4 changed files with 180 additions and 4 deletions

View File

@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
- Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
- Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
- Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so `/agents` no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.
- Gateway/auth follow-up hardening: preserve systemd `EnvironmentFile=` precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
- Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.

View File

@ -8,7 +8,7 @@ import type { AppViewState } from "./app-view-state.ts";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
import { loadAgents, loadToolsCatalog, saveAgentsConfig } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { loadChatHistory } from "./controllers/chat.ts";
import {
@ -712,7 +712,7 @@ export function renderApp(state: AppViewState) {
}
},
onConfigReload: () => loadConfig(state),
onConfigSave: () => saveConfig(state),
onConfigSave: () => saveAgentsConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onSkillsFilterChange: (next) => (state.skillsFilter = next),

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { loadToolsCatalog } from "./agents.ts";
import type { AgentsState } from "./agents.ts";
import { loadAgents, loadToolsCatalog, saveAgentsConfig } from "./agents.ts";
import type { AgentsConfigSaveState, AgentsState } from "./agents.ts";
function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
@ -20,6 +20,83 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
return { state, request };
}
function createSaveState(): {
state: AgentsConfigSaveState;
request: ReturnType<typeof vi.fn>;
} {
const { state, request } = createState();
return {
state: {
...state,
configSaving: false,
configSnapshot: { hash: "hash-1" },
configFormDirty: true,
configFormMode: "form",
configForm: { agents: { list: [{ id: "main" }] } },
configRaw: "{}",
configSchema: null,
lastError: null,
},
request,
};
}
describe("loadAgents", () => {
it("preserves selected agent when it still exists in the list", async () => {
const { state, request } = createState();
state.agentsSelectedId = "kimi";
request.mockResolvedValue({
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [
{ id: "main", name: "main" },
{ id: "kimi", name: "kimi" },
],
});
await loadAgents(state);
expect(state.agentsSelectedId).toBe("kimi");
});
it("resets to default when selected agent is removed", async () => {
const { state, request } = createState();
state.agentsSelectedId = "removed-agent";
request.mockResolvedValue({
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [
{ id: "main", name: "main" },
{ id: "kimi", name: "kimi" },
],
});
await loadAgents(state);
expect(state.agentsSelectedId).toBe("main");
});
it("sets default when no agent is selected", async () => {
const { state, request } = createState();
state.agentsSelectedId = null;
request.mockResolvedValue({
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [
{ id: "main", name: "main" },
{ id: "kimi", name: "kimi" },
],
});
await loadAgents(state);
expect(state.agentsSelectedId).toBe("main");
});
});
describe("loadToolsCatalog", () => {
it("loads catalog and stores result", async () => {
const { state, request } = createState();
@ -59,3 +136,80 @@ describe("loadToolsCatalog", () => {
expect(state.toolsCatalogLoading).toBe(false);
});
});
describe("saveAgentsConfig", () => {
it("restores the pre-save agent after reload when it still exists", async () => {
const { state, request } = createSaveState();
state.agentsSelectedId = "kimi";
request
.mockImplementationOnce(async () => undefined)
.mockImplementationOnce(async () => {
state.agentsSelectedId = null;
return {
hash: "hash-2",
raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi"}]}}',
config: {
agents: {
list: [{ id: "main" }, { id: "kimi" }],
},
},
valid: true,
issues: [],
};
})
.mockImplementationOnce(async () => {
state.agentsSelectedId = null;
return {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [
{ id: "main", name: "main" },
{ id: "kimi", name: "kimi" },
],
};
});
await saveAgentsConfig(state);
expect(request).toHaveBeenNthCalledWith(
1,
"config.set",
expect.objectContaining({ baseHash: "hash-1" }),
);
expect(JSON.parse(request.mock.calls[0]?.[1]?.raw as string)).toEqual({
agents: { list: [{ id: "main" }] },
});
expect(request).toHaveBeenNthCalledWith(2, "config.get", {});
expect(request).toHaveBeenNthCalledWith(3, "agents.list", {});
expect(state.agentsSelectedId).toBe("kimi");
});
it("falls back to the default agent when the saved agent disappears", async () => {
const { state, request } = createSaveState();
state.agentsSelectedId = "kimi";
request
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
hash: "hash-2",
raw: '{"agents":{"list":[{"id":"main"}]}}',
config: {
agents: {
list: [{ id: "main" }],
},
},
valid: true,
issues: [],
})
.mockResolvedValueOnce({
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main", name: "main" }],
});
await saveAgentsConfig(state);
expect(state.agentsSelectedId).toBe("main");
});
});

View File

@ -1,5 +1,6 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
import { saveConfig } from "./config.ts";
export type AgentsState = {
client: GatewayBrowserClient | null;
@ -13,6 +14,17 @@ export type AgentsState = {
toolsCatalogResult: ToolsCatalogResult | null;
};
export type AgentsConfigSaveState = AgentsState & {
configSaving: boolean;
configSnapshot: { hash?: string | null } | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
configForm: Record<string, unknown> | null;
configRaw: string;
configSchema: unknown;
lastError: string | null;
};
export async function loadAgents(state: AgentsState) {
if (!state.client || !state.connected) {
return;
@ -62,3 +74,12 @@ export async function loadToolsCatalog(state: AgentsState, agentId?: string | nu
state.toolsCatalogLoading = false;
}
}
export async function saveAgentsConfig(state: AgentsConfigSaveState) {
const selectedBefore = state.agentsSelectedId;
await saveConfig(state);
await loadAgents(state);
if (selectedBefore && state.agentsList?.agents.some((entry) => entry.id === selectedBefore)) {
state.agentsSelectedId = selectedBefore;
}
}