fix(ui): create automations for selected agent in all-agents view (#123381)

* fix(ui): preserve selected automation owner

Keep the concrete selected agent on model lookup and new automation creation while the Automations page is filtered to All agents, avoiding AgentSelectionRequiredError in multi-agent setups.

* fix(ci): remove duplicate Codex test shard assignment

Keep run-attempt-state.test.ts in the light Codex app-server shard only so the full-suite inventory remains one-to-one.
This commit is contained in:
Peter Steinberger
2026-08-13 17:50:15 -07:00
committed by GitHub
parent 746359d55e
commit bbe97388d3
5 changed files with 163 additions and 20 deletions
+119
View File
@@ -0,0 +1,119 @@
// Control UI browser proof covers explicit automation ownership across widened page scope.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { expect, it } from "vitest";
import { installMockGateway, type MockGatewayRequest } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI cron agent ownership E2E",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`,
});
const requireRecord = createRequireRecord("record", "expected-object-value");
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
return requireRecord(request.params);
}
function cronListResponse(jobs: unknown[]) {
return {
jobs,
snapshotRevision: "cron-agent-ownership-fixture",
total: jobs.length,
offset: 0,
limit: 50,
hasMore: false,
nextOffset: null,
};
}
suite.define(() => {
it("keeps the selected agent as owner while browsing all agents", async () => {
const createdJob = {
id: "weekday-report",
agentId: "main",
name: "Weekday report",
enabled: true,
createdAtMs: Date.parse("2026-05-29T08:00:00.000Z"),
updatedAtMs: Date.parse("2026-05-29T08:05:00.000Z"),
schedule: { kind: "every", everyMs: 1_800_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Prepare the weekday report" },
state: {},
};
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1_280 },
},
async ({ page }) => {
const gateway = await installMockGateway(page, {
assistantName: "Assistant",
methodResponses: {
"agents.list": {
agents: [
{ id: "main", identity: { name: "Assistant" }, name: "Assistant" },
{ id: "writer", identity: { name: "Writer" }, name: "Writer" },
],
defaultId: "main",
mainKey: "main",
scope: "agent",
},
"cron.add": { id: createdJob.id },
"cron.list": {
cases: [
{ match: { lastRunStatus: "error" }, response: cronListResponse([]) },
{ response: cronListResponse([]) },
],
},
"cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false },
"cron.status": { enabled: true, jobs: 0, nextWakeAtMs: null },
},
});
await page.goto(`${suite.server.baseUrl}cron`);
await gateway.waitForRequest("agents.list");
const pageScope = page.locator(".agent-scope-control openclaw-agent-select");
await pageScope.locator(".agent-select__trigger").click();
await pageScope
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "All agents" })
.click();
await expect
.poll(() =>
pageScope.evaluate((picker) => (picker as HTMLElement & { value: string }).value),
)
.toBe("");
await page.locator('[data-test-id="cron-new-task"]').click();
await page.locator("#cron-name").fill(createdJob.name);
await page.locator("#cron-payload-text").fill(createdJob.payload.message);
await gateway.setMethodResponse("cron.list", {
cases: [
{ match: { lastRunStatus: "error" }, response: cronListResponse([]) },
{ response: cronListResponse([createdJob]) },
],
});
await page.locator('[data-test-id="cron-submit"]').click();
expect(requestParams(await gateway.waitForRequest("models.list"))).toEqual({
agentId: "main",
view: "configured",
preparedOnly: true,
});
expect(requestParams(await gateway.waitForRequest("cron.add"))).toMatchObject({
agentId: "main",
name: createdJob.name,
payload: createdJob.payload,
});
await page
.locator(".cron-table__name-text", { hasText: createdJob.name })
.waitFor({ state: "visible", timeout: 10_000 });
},
);
});
});
+11 -6
View File
@@ -280,9 +280,10 @@ describe("cron controller", () => {
cronModelSuggestions: [],
};
await loadCronModelSuggestions(state);
await loadCronModelSuggestions(state, "writer");
expect(request).toHaveBeenCalledWith("models.list", {
agentId: "writer",
view: "configured",
preparedOnly: true,
});
@@ -1338,9 +1339,13 @@ describe("cron controller", () => {
});
it.each([
{ scenario: "all agents", cronAgentId: null, expectedAgentId: "" },
{ scenario: "the default agent", cronAgentId: "main", expectedAgentId: "main" },
{ scenario: "a selected agent", cronAgentId: "writer", expectedAgentId: "writer" },
{
scenario: "an all-agent filter with a selected owner",
cronAgentId: null,
selectedAgentId: "writer",
},
{ scenario: "the default agent", cronAgentId: "main", selectedAgentId: "main" },
{ scenario: "a selected agent", cronAgentId: "writer", selectedAgentId: "writer" },
])("canceling edit resets form for $scenario and clears edit mode", (scenario) => {
const state = createState({ cronAgentId: scenario.cronAgentId });
const job = createCronJob({
@@ -1354,12 +1359,12 @@ describe("cron controller", () => {
state.cronForm.name = "changed";
state.cronFieldErrors = { name: "Name is required." };
cancelCronEdit(state);
cancelCronEdit(state, scenario.selectedAgentId);
expect(state.cronEditingJobId).toBeNull();
expect(state.cronForm).toEqual({
...DEFAULT_CRON_FORM,
agentId: scenario.expectedAgentId,
agentId: scenario.selectedAgentId,
});
// Fresh forms start visually clean; validation re-arms on change/submit.
expect(state.cronFieldErrors).toEqual({});
+11 -7
View File
@@ -389,12 +389,16 @@ export async function loadCronStatus(state: CronState) {
}
}
export async function loadCronModelSuggestions(state: CronModelSuggestionsState) {
if (!state.client || !state.connected) {
export async function loadCronModelSuggestions(
state: CronModelSuggestionsState,
agentId: string | null,
) {
if (!state.client || !state.connected || !agentId) {
return;
}
try {
const res = await state.client.request("models.list", {
agentId,
view: "configured",
preparedOnly: true,
});
@@ -758,8 +762,8 @@ function clearCronRunsPage(state: CronState) {
state.cronRunsNextOffset = null;
}
function resetCronFormToDefaults(state: CronState) {
state.cronForm = { ...DEFAULT_CRON_FORM, agentId: state.cronAgentId ?? "" };
function resetCronFormToDefaults(state: CronState, agentId: string | null) {
state.cronForm = { ...DEFAULT_CRON_FORM, agentId: agentId ?? "" };
// A fresh form starts visually clean; validation re-arms on the first change
// or submit so required-field errors do not greet the user immediately.
state.cronFieldErrors = {};
@@ -1204,7 +1208,7 @@ export async function addCronJob(state: CronState): Promise<CronSaveResult> {
result = { saved: true, jobId: editedJobId };
} else {
const response = await client.request("cron.add", job);
resetCronFormToDefaults(state);
resetCronFormToDefaults(state, agentId);
result = { saved: true, jobId: extractSavedCronJobId(response) };
}
await reloadCronJobsSnapshot(state);
@@ -1485,8 +1489,8 @@ export function startCronClone(state: CronState, job: CronJob) {
state.cronFieldErrors = validateCronForm(state.cronForm);
}
export function cancelCronEdit(state: CronState) {
export function cancelCronEdit(state: CronState, agentId: string | null) {
clearCronEditState(state);
resetCronFormToDefaults(state);
resetCronFormToDefaults(state, agentId);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+19 -4
View File
@@ -66,9 +66,13 @@ function createGateway(client: GatewayBrowserClient, connected: boolean): TestGa
} as unknown as TestGateway;
}
function createContext(gateway: TestGateway, scopeId: string | null = "main"): ApplicationContext {
function createContext(
gateway: TestGateway,
scopeId: string | null = "main",
selectedId: string | null = scopeId,
): ApplicationContext {
const subscribe = () => () => undefined;
let selectionState = { selectedId: scopeId, scopeId };
let selectionState = { selectedId, scopeId };
const selectionListeners = new Set<(state: typeof selectionState) => void>();
return {
basePath: "",
@@ -262,13 +266,24 @@ describe("CronPage editor state sync", () => {
{
scenario: "a new task from the all-agents view",
scopeId: null,
selectedId: "writer",
suggested: false,
expectedAgentId: undefined,
expectedAgentId: "writer",
},
])("creates $scenario with its intended agent ownership", async (scenario) => {
const request = createRequest();
const gateway = createGateway({ request } as unknown as GatewayBrowserClient, true);
const page = createPage(createContext(gateway, scenario.scopeId), { render: true });
const page = createPage(
createContext(gateway, scenario.scopeId, scenario.selectedId ?? scenario.scopeId),
{ render: true },
);
await waitForCronPage(() => {
expect(request).toHaveBeenCalledWith("models.list", {
agentId: scenario.expectedAgentId,
view: "configured",
preparedOnly: true,
});
});
const createSelector = scenario.suggested
? '[data-suggestion="repoPulse"]'
+3 -3
View File
@@ -210,7 +210,7 @@ class CronPage extends OpenClawLightDomElement {
connected: cronState.connected,
cronModelSuggestions: this.cronModelSuggestions,
};
await loadCronModelSuggestions(suggestionState);
await loadCronModelSuggestions(suggestionState, this.context.agentSelection.state.selectedId);
if (
this.isConnected &&
this.cron === cronState &&
@@ -269,7 +269,7 @@ class CronPage extends OpenClawLightDomElement {
if (!this.canManageCron) {
return;
}
cancelCronEdit(this.cron);
cancelCronEdit(this.cron, this.context.agentSelection.state.selectedId);
this.cron.cronCreateOpen = true;
if (patch) {
this.patchForm(patch);
@@ -334,7 +334,7 @@ class CronPage extends OpenClawLightDomElement {
}
private closePanel() {
cancelCronEdit(this.cron);
cancelCronEdit(this.cron, this.context.agentSelection.state.selectedId);
this.cron.cronCreateOpen = false;
this.requestCronUpdate();
void this.runCronTask(async (cronState) => {