diff --git a/ui/src/e2e/cron-agent-ownership.e2e.test.ts b/ui/src/e2e/cron-agent-ownership.e2e.test.ts new file mode 100644 index 000000000000..c23cdfc0d3a9 --- /dev/null +++ b/ui/src/e2e/cron-agent-ownership.e2e.test.ts @@ -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 { + 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 }); + }, + ); + }); +}); diff --git a/ui/src/lib/cron/index.test.ts b/ui/src/lib/cron/index.test.ts index 4f2dd2a12328..818a5831f444 100644 --- a/ui/src/lib/cron/index.test.ts +++ b/ui/src/lib/cron/index.test.ts @@ -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({}); diff --git a/ui/src/lib/cron/index.ts b/ui/src/lib/cron/index.ts index 1394ac49e734..6875e6800294 100644 --- a/ui/src/lib/cron/index.ts +++ b/ui/src/lib/cron/index.ts @@ -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 { 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. */ diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 3298e1380161..b75532d240e0 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -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"]' diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts index 285232809017..1742011be366 100644 --- a/ui/src/pages/cron/cron-page.ts +++ b/ui/src/pages/cron/cron-page.ts @@ -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) => {