mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(control-ui): persist Set Default agent through config save
This commit is contained in:
@@ -53,6 +53,7 @@ import {
|
||||
resetToolsEffectiveState,
|
||||
refreshVisibleToolsEffectiveForCurrentSession,
|
||||
saveAgentsConfig,
|
||||
setDefaultAgent,
|
||||
} from "./controllers/agents.ts";
|
||||
import { setAssistantAvatarOverride } from "./controllers/assistant-identity.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
@@ -66,7 +67,6 @@ import {
|
||||
resetConfigPendingChanges,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
stageDefaultAgentConfigEntry,
|
||||
stageConfigPreset,
|
||||
updateConfigRawValue,
|
||||
updateConfigFormValue,
|
||||
@@ -3480,7 +3480,7 @@ export function renderApp(state: AppViewState) {
|
||||
updateConfigFormValue(state, basePathResult, { primary, fallbacks: normalized });
|
||||
},
|
||||
onSetDefault: (agentId) => {
|
||||
stageDefaultAgentConfigEntry(state, agentId);
|
||||
void setDefaultAgent(state, agentId);
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Control UI tests cover agents behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadAgents, loadToolsCatalog, loadToolsEffective, saveAgentsConfig } from "./agents.ts";
|
||||
import {
|
||||
loadAgents,
|
||||
loadToolsCatalog,
|
||||
loadToolsEffective,
|
||||
saveAgentsConfig,
|
||||
setDefaultAgent,
|
||||
} from "./agents.ts";
|
||||
import type { AgentsConfigSaveState, AgentsState } from "./agents.ts";
|
||||
|
||||
type TestRequest = (method: string, payload?: unknown) => Promise<unknown>;
|
||||
@@ -430,3 +436,72 @@ describe("saveAgentsConfig", () => {
|
||||
expect(state.agentsSelectedId).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDefaultAgent", () => {
|
||||
it("stages the canonical default flag and persists it through config.set", async () => {
|
||||
const { state, request } = createSaveState();
|
||||
state.configForm = { agents: { list: [{ id: "main" }, { id: "kimi" }] } };
|
||||
state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } };
|
||||
state.configFormDirty = false;
|
||||
request
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
hash: "hash-2",
|
||||
raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi","default":true}]}}',
|
||||
config: { agents: { list: [{ id: "main" }, { id: "kimi", default: true }] } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
defaultId: "kimi",
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
agents: [
|
||||
{ id: "main", name: "main" },
|
||||
{ id: "kimi", name: "kimi" },
|
||||
],
|
||||
});
|
||||
|
||||
await setDefaultAgent(state, "kimi");
|
||||
|
||||
const [method, params] = requireFirstRequestCall(request);
|
||||
const requestParams = requireRecord(params);
|
||||
expect(method).toBe("config.set");
|
||||
expect(JSON.parse(String(requestParams.raw))).toEqual({
|
||||
agents: { list: [{ id: "main" }, { id: "kimi", default: true }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist when the agent is absent from the config list", async () => {
|
||||
const { state, request } = createSaveState();
|
||||
state.configForm = { agents: { list: [{ id: "main" }] } };
|
||||
|
||||
await setDefaultAgent(state, "ghost");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist unrelated dirty agent config drafts", async () => {
|
||||
const { state, request } = createSaveState();
|
||||
state.configFormDirty = true;
|
||||
state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } };
|
||||
state.configForm = {
|
||||
agents: {
|
||||
list: [{ id: "main", model: "gpt-5.5" }, { id: "kimi" }],
|
||||
},
|
||||
};
|
||||
|
||||
await setDefaultAgent(state, "kimi");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.configForm).toEqual({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", model: "gpt-5.5" },
|
||||
{ id: "kimi", default: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ToolsCatalogResult,
|
||||
ToolsEffectiveResult,
|
||||
} from "../types.ts";
|
||||
import { saveConfig } from "./config.ts";
|
||||
import { saveConfig, stageDefaultAgentConfigEntry } from "./config.ts";
|
||||
import type { ConfigState } from "./config.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
@@ -246,3 +246,18 @@ export async function saveAgentsConfig(state: AgentsConfigSaveState) {
|
||||
state.agentsSelectedId = selectedBefore;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultAgent(
|
||||
state: AgentsConfigSaveState,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const hadPendingConfigDraft = state.configFormDirty;
|
||||
// Set Default is a one-click action on a clean draft, but saveConfig serializes the
|
||||
// whole form. If other edits were already dirty, keep them staged for the explicit
|
||||
// Save button instead of committing unrelated pending config changes.
|
||||
if (stageDefaultAgentConfigEntry(state, agentId)) {
|
||||
if (!hadPendingConfigDraft && state.configFormDirty) {
|
||||
await saveAgentsConfig(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Control UI tests cover Agents page Set Default persistence behavior.
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayRequest,
|
||||
} from "../../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Expected object value");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
|
||||
return requireRecord(request.params);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI agents Set Default mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("persists Set Default through config.set instead of only staging the form draft", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
assistantName: "Main agent",
|
||||
defaultAgentId: "main",
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
agents: [
|
||||
{ id: "main", name: "Main agent" },
|
||||
{ id: "kimi", name: "Kimi agent" },
|
||||
],
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "agent",
|
||||
},
|
||||
"config.get": {
|
||||
config: { agents: { list: [{ id: "main" }, { id: "kimi" }] } },
|
||||
hash: "hash-1",
|
||||
issues: [],
|
||||
raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi"}]}}',
|
||||
valid: true,
|
||||
},
|
||||
"config.set": {
|
||||
config: { agents: { list: [{ id: "main" }, { id: "kimi", default: true }] } },
|
||||
hash: "hash-2",
|
||||
issues: [],
|
||||
raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi","default":true}]}}',
|
||||
valid: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}agents`);
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
// selectOption / click auto-wait for the element to be actionable (enabled), so
|
||||
// these implicitly assert the dropdown loaded and Set Default is clickable for a
|
||||
// non-default agent.
|
||||
await page.locator("select.agents-select").selectOption("kimi");
|
||||
await page.getByRole("button", { name: "Set Default", exact: true }).click();
|
||||
|
||||
// The fix routes Set Default through the canonical save path; without it the click
|
||||
// only stages a form draft and never emits config.set, so this request never arrives.
|
||||
const setRequest = await gateway.waitForRequest("config.set");
|
||||
const raw = requestParams(setRequest).raw;
|
||||
expect(JSON.parse(String(raw))).toEqual({
|
||||
agents: { list: [{ id: "main" }, { id: "kimi", default: true }] },
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user