From 4373965b702a9707c395c86f912421ce51db80f3 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:00:35 +0800 Subject: [PATCH] fix(app): select the intended agent for cadence settings Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../automation-cadence-settings.tsx | 25 +++++----- .../src/features/personal-workspace/i18n.tsx | 6 +++ .../personal-workspace/personal-workspace.css | 2 +- .../workspace-settings-page.tsx | 2 +- .../rfcs/automatic-execution-admission-v0.md | 6 ++- .../automatic-execution-admission-v0.zh-CN.md | 2 + .../automation-cadence.mjs | 49 ++++++++++++++++--- tests/test_chat_automation_cadence_api.py | 30 ++++++++++++ 8 files changed, 97 insertions(+), 25 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx index 2555564ec8..883977f09e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/automation-cadence-settings.tsx @@ -12,10 +12,9 @@ type Scope = "goal" | "agent" | "automation"; export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGoal }>) { const { t } = useWorkspaceI18n(); - const agentLanes = useMemo(() => goal.agentLanes?.length - ? goal.agentLanes : goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : [], [goal]); - const [scope, setScope] = useState("goal"); - const [agentId, setAgentId] = useState(agentLanes[0]?.agentId ?? ""); + const agentLanes = useMemo(() => goal.agentLanes ?? (goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : []), [goal]); + const [scope, setScope] = useState("agent"); + const [agentId, setAgentId] = useState(""); const [automationId, setAutomationId] = useState(""); const [inspection, setInspection] = useState(null); const [minutes, setMinutes] = useState("0"); @@ -28,14 +27,10 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo const [reloadSequence, setReloadSequence] = useState(0); const scopedAgent = scope === "goal" ? null : agentId.trim() || null; const scopedAutomation = scope === "automation" ? automationId.trim() || null : null; - const scopeReady = scope === "goal" || Boolean(scopedAgent && (scope !== "automation" || scopedAutomation)); + const scopeReady = scope === "goal" || Boolean(scopedAgent && agentLanes.some((lane) => lane.agentId === scopedAgent) && (scope !== "automation" || scopedAutomation)); useEffect(() => { - setAgentId(agentLanes[0]?.agentId ?? ""); - }, [goal.goalId]); - - useEffect(() => { - if (!scopeReady) { setInspection(null); setPreview(null); return; } + if (!scopeReady) { setInspection(null); setPreview(null); setBusy(null); return; } let active = true; setBusy("load"); setError(null); @@ -53,7 +48,7 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo .catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : t("cadence.loadFailed")); }) .finally(() => { if (active) setBusy(null); }); return () => { active = false; }; - }, [goal.goalId, scope, scopedAgent, scopedAutomation, reloadSequence, t]); + }, [goal.goalId, scope, scopedAgent, scopedAutomation, scopeReady, reloadSequence, t]); const directRule = inspection?.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation); const inheritedFloor = Math.max(0, ...(inspection?.sources.filter((source) => source !== directRule).map((source) => source.min_interval_minutes) ?? [])); @@ -109,8 +104,10 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo )} {scope !== "goal" ?
- - {agentLanes.map((lane) => )} + {scope === "automation" ? : null}
: null} {busy === "load" ?

{t("common.loading")}

: null} @@ -127,7 +124,7 @@ export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGo {reduction ? : null} - {preview ?
{t("cadence.preview")}{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}{t("cadence.previewLocked")}
: null} + {preview ?
{t("cadence.preview")}{goal.title} · {scopedAgent ?? t("cadence.scope.goal")}{scopedAutomation ? ` · ${scopedAutomation}` : ""}{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}{t("cadence.previewLocked")}
: null}
: null} {error ?

{error}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 266255795e..b1b2086485 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -993,6 +993,9 @@ const en = { "cadence.scope.agent": "One Agent", "cadence.scope.automation": "One automation", "cadence.agentId": "Agent", + "cadence.chooseAgent": "Choose an Agent", + "cadence.noAgents": "No registered Agents in this Goal", + "cadence.agentHint": "Choose from this Goal’s Agents. The minimum applies only to the selected Agent.", "cadence.automationId": "Automation ID", "cadence.effective": "Effective minimum", "cadence.unconfigured": "No minimum configured yet.", @@ -2146,6 +2149,9 @@ const zhCN: Record = { "cadence.scope.agent": "单个 Agent", "cadence.scope.automation": "单个自动化", "cadence.agentId": "Agent", + "cadence.chooseAgent": "请选择 Agent", + "cadence.noAgents": "此 Goal 暂无已注册 Agent", + "cadence.agentHint": "从此 Goal 的 Agent 中选择;下限仅针对所选 Agent 生效。", "cadence.automationId": "自动化 ID", "cadence.effective": "生效下限", "cadence.unconfigured": "尚未设置间隔下限。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index bf46b88713..9d53db8dff 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -543,7 +543,7 @@ .personal-cadence-scopes input { accent-color: var(--pw-blue); } .personal-cadence-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .personal-cadence-fields label { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: 12px; font-weight: 700; } -.personal-cadence-fields input { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: #fff; color: var(--pw-text); font: inherit; font-size: 13px; } +.personal-cadence-fields input, .personal-cadence-fields select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: #fff; color: var(--pw-text); font: inherit; font-size: 13px; } .personal-cadence-fields small { color: var(--pw-muted); font-size: 11px; font-weight: 400; line-height: 1.5; } .personal-cadence-readback { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--pw-line); border-radius: 8px; } .personal-cadence-readback small { color: var(--pw-muted); font-size: 11px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index c00398bcaf..ae68f393f3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -199,7 +199,7 @@ export function WorkspaceSettingsPage({ onChanged={onChanged} /> ) : null} - {tab === "cadence" && selectedGoal ? : null} + {tab === "cadence" && selectedGoal ? : null} {tab === "appearance" ? (
diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.md b/docs/architecture/rfcs/automatic-execution-admission-v0.md index 0c5ac177d4..0ea8fc8369 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.md @@ -234,7 +234,11 @@ start, so an older or hand-edited file fails closed rather than resuming. The M3 settings companion presents the quota-owned Goal/agent/automation policy through a revision-locked local preview, apply and readback, and reports stale -configuration intent as a typed conflict instead of parsing error text. It does +configuration intent as a typed conflict instead of parsing error text. Settings +start with an explicit Agent choice from the Goal’s projected Agent list, rather +than preselecting an arbitrary Agent or filtering suggestions by its current ID. +Switching the target invalidates the preview; the preview names its Goal and +Agent. Per-Agent overrides preserve peer and Goal defaults. It does not edit existing Codex App timers, and next-eligible time plus Lark/CLI wait parity remain open. The App timer-to-hook path, non-Turn launchers and live model-host promotion remain unqualified; M4 remains a design option. No existing diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md index 5aef2ef579..d0959223aa 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md @@ -175,6 +175,8 @@ fail-closed,显式手动理由也无法绕过。缺少阶段字段的旧记录 M3 设置页阶段成果复用 quota 权威,提供 Goal/Agent/Automation 作用域的修订号锁定 预览、应用与读回,并把过期的配置意图作为 typed conflict 报出,而不是解析错误文案。 +默认从 Goal 投影的 Agent 列表中明确选择目标,不预选任意 Agent,也不按当前 ID 过滤候选。 +切换目标会清除旧预览;预览展示 Goal 和 Agent。单个 Agent 的下限不会修改其他 Agent 或 Goal 默认值。 它不修改已有 Codex App 定时器;下次可运行时间及 Lark/CLI 等待反馈一致性仍未完成。 App 定时器到 hook、非 Turn launcher 及真实模型宿主推广仍未验收,M4 仍是设计选项。 本提案不激活、不改绑任何已有自动化;测试和 PR 必须区分确定性验证与宿主推广。 diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index 4b606541a5..daaa5f08c9 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -18,20 +18,20 @@ export const automationCadenceScenario = { const goalId = body?.goal_id ?? parsed.searchParams.get("goal_id"); const agentId = body?.agent_id ?? parsed.searchParams.get("agent_id") ?? null; const automationId = body?.automation_id ?? parsed.searchParams.get("automation_id") ?? null; - const applicable = (rows) => rows.filter((rule) => rule.agent_id === null - || (rule.agent_id === agentId && (rule.automation_id === null || rule.automation_id === automationId))); + const applicable = (rows) => rows.filter((rule) => rule.goal_id === goalId && (rule.agent_id === null + || (rule.agent_id === agentId && (rule.automation_id === null || rule.automation_id === automationId)))); if (body && body.expected_revision !== revision) { await route.fulfill({ status: 409, json: { error: "configuration revision conflict" } }); return; } - const prior = rules.find((rule) => rule.agent_id === agentId && rule.automation_id === automationId); + const prior = rules.find((rule) => rule.goal_id === goalId && rule.agent_id === agentId && rule.automation_id === automationId); if (body && prior && body.min_interval_minutes < prior.min_interval_minutes && !body.approve_reduction) { await route.fulfill({ status: 400, json: { error: "reduction requires explicit owner approval" } }); return; } if (body) { const changed = [...rules.filter((rule) => rule !== prior), { - agent_id: agentId, automation_id: automationId, min_interval_minutes: body.min_interval_minutes, + goal_id: goalId, agent_id: agentId, automation_id: automationId, min_interval_minutes: body.min_interval_minutes, }]; if (parsed.pathname.endsWith("/apply")) { rules = changed; @@ -63,15 +63,22 @@ export const automationCadenceScenario = { }); const { page, close, checkpointCoverage, errors } = context; try { - await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.locator(".personal-goal-link", { hasText: "Multi Agent Projection" }).click(); await page.getByRole("button", { name: "Goal 设置", exact: true }).click(); const target = page.locator(".personal-settings-goal-target"); - await target.getByText("Product Release", { exact: true }).waitFor(); + await target.getByText("Multi Agent Projection", { exact: true }).waitFor(); await page.getByRole("button", { name: "能力中心" }).click(); if (await target.count()) throw new Error("Machine settings retained a Goal-specific target"); await page.getByRole("button", { name: "自动执行间隔" }).click(); - await target.getByText("Product Release", { exact: true }).waitFor(); + await target.getByText("Multi Agent Projection", { exact: true }).waitFor(); const panel = page.getByRole("region", { name: "自动执行间隔" }); + const agent = panel.getByRole("combobox", { name: "Agent", exact: true }); + if (await agent.inputValue() !== "") throw new Error("Cadence silently selected an Agent"); + const candidates = await agent.locator("option").evaluateAll((options) => options.map((option) => option.value)); + for (const id of ["codex-latest-lane", "codex-older-lane"]) { + if (!candidates.includes(id)) throw new Error(`Missing Agent candidate: ${id}`); + } + await panel.getByRole("radio", { name: "整个 Goal" }).check(); await panel.getByText("0 分钟", { exact: true }).first().waitFor(); if (!await panel.getByText("App 定时触发到启动前钩子的拦截尚未验证。", { exact: false }).count()) { throw new Error("Cadence settings overstated App enforcement"); @@ -101,15 +108,41 @@ export const automationCadenceScenario = { throw new Error("Explicit reduction remained unavailable"); } await panel.getByRole("radio", { name: "单个 Agent" }).check(); + await agent.selectOption("codex-latest-lane"); await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); await panel.getByText("60 分钟", { exact: true }).first().waitFor(); + await panel.getByLabel("最短间隔(分钟)").fill("120"); + await panel.getByLabel("所有者指令或原因").fill("Owner requested two-hour minimum for this Agent"); + await panel.getByRole("button", { name: "预览变更", exact: true }).click(); + await panel.getByText("变更后生效下限:120 分钟").waitFor(); + await agent.selectOption("codex-older-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); + if (await panel.getByRole("button", { name: "应用已预览变更" }).isEnabled()) { + throw new Error("Changing Agent retained the previous Agent's preview"); + } + await agent.selectOption("codex-latest-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); + await panel.getByLabel("最短间隔(分钟)").fill("120"); + await panel.getByLabel("所有者指令或原因").fill("Owner requested two-hour minimum for this Agent"); + await panel.getByRole("button", { name: "预览变更", exact: true }).click(); + await panel.getByText("变更后生效下限:120 分钟").waitFor(); + await panel.getByRole("button", { name: "应用已预览变更" }).click(); + await panel.getByText("本层设置 120 分钟;上层下限 60 分钟。").waitFor(); + await page.screenshot({ path: resolve(outputDir, "desktop-automation-cadence-agent.png"), animations: "disabled" }); + await agent.selectOption("codex-older-lane"); + await panel.getByText("继承上层 60 分钟;本层未设置。").waitFor(); + await agent.selectOption("codex-latest-lane"); + await panel.getByText("本层设置 120 分钟;上层下限 60 分钟。").waitFor(); + if (revision !== 2 || rules.filter((rule) => rule.agent_id !== null).length !== 1) { + throw new Error("Agent override changed another scope"); + } await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: resolve(outputDir, "mobile-automation-cadence-inherited.png"), fullPage: false, animations: "disabled" }); const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1); if (overflow) throw new Error("Cadence settings overflowed the mobile viewport"); if (errors.length) throw new Error(`Browser errors: ${errors.join(" | ")}`); await checkpointCoverage(); - return { coverageEntries: await close(), note: "Goal policy preview, apply/readback, Agent inheritance, App boundary and desktop/mobile layouts verified." }; + return { coverageEntries: await close(), note: "Explicit Agent selection, complete candidates, scope-isolated 120-minute override, stale preview invalidation, inheritance, App boundary and desktop/mobile layouts verified." }; } catch (error) { await close(); throw error; diff --git a/tests/test_chat_automation_cadence_api.py b/tests/test_chat_automation_cadence_api.py index 3731140532..e5bf0552b7 100644 --- a/tests/test_chat_automation_cadence_api.py +++ b/tests/test_chat_automation_cadence_api.py @@ -126,6 +126,36 @@ def test_chat_cadence_preview_apply_inheritance_and_stale_rejection( port, "POST", f"{path}/preview", {**lower, "approve_reduction": True} ) assert status == 200 and approved["min_interval_minutes"] == 30 + + # A per-Agent setting must not become a Goal default or affect a peer. + agent_change = { + **change, + "agent_id": "agent-a", + "min_interval_minutes": 120, + "expected_revision": applied["configuration_revision"], + } + status, agent_preview = _exchange(port, "POST", f"{path}/preview", agent_change) + assert status == 200 and agent_preview["min_interval_minutes"] == 120 + status, agent_applied = _exchange( + port, + "POST", + f"{path}/apply", + { + **agent_change, + "preview_revision": agent_preview["preview_revision"], + }, + ) + assert status == 200 and agent_applied["readback_verified"] is True + for agent_id, expected in (("agent-a", 120), ("agent-b", 60)): + agent_query = urlencode({"goal_id": "goal-one", "agent_id": agent_id}) + status, observed = _exchange(port, "GET", f"{path}?{agent_query}") + assert status == 200 and observed["min_interval_minutes"] == expected + assert ( + _exchange(port, "GET", f"{path}?goal_id=goal-one")[1][ + "min_interval_minutes" + ] + == 60 + ) finally: server.shutdown() thread.join(timeout=5)