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:
parent
1e3daa6373
commit
c0a7c302f3
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user