fix(control-ui): persist Set Default agent through config save

This commit is contained in:
Wynne668
2026-06-28 19:15:27 -07:00
committed by GitHub
parent bbc4c56a6d
commit 32d117c68b
4 changed files with 200 additions and 4 deletions
+2 -2
View File
@@ -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);
},
}),
)
+76 -1
View File
@@ -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);
});
});
+16 -1
View File
@@ -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();
}
});
});