mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
746359d55e
commit
bbe97388d3
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"]'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user