diff --git a/CHANGELOG.md b/CHANGELOG.md index 705ca9d6b8e..2bb99a69f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 6006496c1ff..7fbe38c9ca7 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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), diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts index 669f62d6362..8dacdbb20ed 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/ui/controllers/agents.test.ts @@ -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 } { const request = vi.fn(); @@ -20,6 +20,83 @@ function createState(): { state: AgentsState; request: ReturnType return { state, request }; } +function createSaveState(): { + state: AgentsConfigSaveState; + request: ReturnType; +} { + 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"); + }); +}); diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index 69fd091847f..57f6e52d76d 100644 --- a/ui/src/ui/controllers/agents.ts +++ b/ui/src/ui/controllers/agents.ts @@ -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 | 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; + } +}