diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx index d05301ab2..0465b212c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx @@ -121,6 +121,11 @@ export function CapabilityDetailHeader({ capability, locale, source }: Readonly< }>) { const { t } = useWorkspaceI18n(); const localized = localizeCapability(capability, locale); + // Capability ownership remains visible even when this editor is read-only. + const scopes = capability.configuration_editor.supported_scopes; + const scopeKind = scopes.includes("machine") + ? scopes.includes("goal") ? "machineAndGoal" : "machine" + : scopes.includes("goal") ? "goal" : "unknown"; return (
@@ -129,6 +134,7 @@ export function CapabilityDetailHeader({ capability, locale, source }: Readonly<

{localized.display_name}

+

{t(`capabilities.configurationScope.${scopeKind}`)}

{capability.context_contribution && (
{locale === "zh-CN" ? "主 Agent 协作指导" : "Coordinator workflow guidance"} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index e85081adc..d4e48be01 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { AlertTriangle, Code2, LoaderCircle, RefreshCw, ShieldCheck } from "lucide-react"; +import { AlertTriangle, Code2, LoaderCircle, RefreshCw } from "lucide-react"; import { applyGoalConfiguration, @@ -248,6 +248,7 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied locale={locale} onSelect={setSelectedCapabilityId} scope="goal" + showScope={false} selectedCapabilityId={localizedSelected.capability_id} t={t} /> @@ -361,11 +362,7 @@ export function GoalCapabilitySettings({ callbacks, goalId, notification, onChan if (!inspection) return null; return ( -
-
- {t("capabilities.atomicOverride")} -

{t("capabilities.atomicOverrideDescription")}

-
+
= { "capabilities.description": "查看当前 Goal 可用的能力,以及每项配置的准确作用域。", "capabilities.editorPrepared": "已注册 typed editor contract", "capabilities.effectiveSource": "当前生效来源", + "capabilities.configurationScope.machine": "设备全局配置 · 不支持 Goal 覆盖", + "capabilities.configurationScope.goal": "仅单个 Goal 配置 · 无设备默认值", + "capabilities.configurationScope.machineAndGoal": "设备默认配置 · 支持 Goal 单独覆盖", + "capabilities.configurationScope.unknown": "配置范围暂不可用", "capabilities.empty": "当前 Goal 没有可用的能力描述。", "capabilities.fields": "已注册字段", "capabilities.goalPolicy": "Goal 能力策略", @@ -2093,12 +2107,12 @@ const zhCN: Record = { "capabilities.larkInboxNotificationSetting": "Gate 群通知", "capabilities.loading": "正在加载 Goal 能力…", "capabilities.machineOnly": "此能力只能在机器作用域配置。", - "capabilities.machineValue": "实时机器默认值", + "capabilities.machineValue": "设备默认值", "capabilities.machineScope": "机器", "capabilities.previewOnly": "当前读取结果来自真实配置;在 revision-locked preview/apply 路径接通前,Dashboard 不会提供 Goal 写入控件。", "capabilities.preview": "锁定 revision 的变更预览", "capabilities.previewChanges": "预览变更", - "capabilities.restoreInheritance": "恢复继承机器默认值", + "capabilities.restoreInheritance": "恢复沿用设备默认", "capabilities.previewFailed": "无法预览 Goal 能力变更。", "capabilities.previewLocked": "应用时会重新校验这一个 plan revision;过期变更将被拒绝。", "capabilities.partialWrite": "Goal 值已保存;共享投影仍需修复", @@ -2110,8 +2124,8 @@ const zhCN: Record = { "capabilities.revisionLockedReady": "所有变更必须先预览;只有对应的精确 revision 仍然有效时才会写入。", "capabilities.retry": "重试", "capabilities.source.capability_default": "能力默认值", - "capabilities.source.goal_override": "Goal override", - "capabilities.source.machine_default": "实时机器默认值", + "capabilities.source.goal_override": "本 Goal 单独设置", + "capabilities.source.machine_default": "设备默认值", "capabilities.source.not_configured": "未配置", "capabilities.title": "Goal 能力", "settings.close": "关闭设置", @@ -2131,6 +2145,12 @@ const zhCN: Record = { "settings.language": "语言", "settings.modelProvider": "模型 Provider 配置", "settings.globalCapabilities": "能力中心", + "settings.capabilityScope": "配置范围", + "settings.capabilityScope.machine": "此设备默认", + "settings.capabilityScope.goal": "单个 Goal", + "settings.capabilityScope.machineDescription": "适用于此设备;Goal 已单独设置的配置优先生效。", + "settings.capabilityScope.goalDescription": "仅修改所选 Goal。单独设置的能力使用完整配置,其余能力沿用默认值。", + "settings.targetGoal": "目标 Goal", "settings.languageDescription": "选择 LoopX Desktop 工作区使用的界面语言。", "settings.languageEnglishDescription": "使用英文显示导航、设置和工作区控件。", "settings.languageEnglish": "English", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 86f462839..2fea7972a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -337,10 +337,10 @@ export function MachineConfigurationSettings({ section }: { section: "steward" | return (
-
+ {section === "steward" ?
{t("machine.liveDefault")}

{t("machine.liveDefaultDescription")}

-
+
: null} {section === "other" ? : null}
@@ -356,7 +356,7 @@ export function MachineConfigurationSettings({ section }: { section: "steward" | ) : null}
- +
row\.goalId === initialGoalId\)/, "Goal capability settings receive the live Goal Channel notification state"); +assert.match(workspaceSettings, /goalNotifications\.find\(\(row\) => row\.goalId === capabilityGoalId\)/, "Goal capability settings receive the selected Goal Channel notification state"); assert.match(machineSettings, / = { +type SettingsPage = Exclude; + +const tabIcons: Record = { appearance: Palette, - capabilities: SlidersHorizontal, + capabilities: ServerCog, cadence: Clock3, language: Languages, lark: Settings2, - machine: ServerCog, provider: KeyRound, steward: Bot, }; @@ -48,7 +49,10 @@ export function WorkspaceSettingsPage({ theme: WorkspaceTheme; }) { const { locale, setLocale, t } = useWorkspaceI18n(); - const [tab, setTab] = useState(initialTab); + // Preserve existing Goal-settings links while presenting one capability entry. + const [tab, setTab] = useState(initialTab === "machine" ? "capabilities" : initialTab); + const [capabilityScope, setCapabilityScope] = useState<"machine" | "goal">(initialTab === "capabilities" ? "goal" : "machine"); + const [capabilityGoalId, setCapabilityGoalId] = useState(initialGoalId ?? ""); const tabsRef = useRef(null); useEffect(() => { const navigation = tabsRef.current; @@ -68,7 +72,7 @@ export function WorkspaceSettingsPage({ observer.observe(navigation); return () => observer.disconnect(); }, [tab]); - const tabGroups: Array<{ label: string; tabs: Array<{ key: WorkspaceSettingsTab; label: string }> }> = [ + const tabGroups: Array<{ label: string; tabs: Array<{ key: SettingsPage; label: string }> }> = [ { label: t("settings.agentGroup"), tabs: [ @@ -78,8 +82,7 @@ export function WorkspaceSettingsPage({ // machine defaults every Goal inherits). They answer different questions // and are edited on different surfaces, so they are separate categories. { key: "provider", label: t("settings.modelProvider") }, - { key: "machine", label: t("settings.globalCapabilities") }, - ...(initialGoalId ? [{ key: "capabilities" as const, label: t("capabilities.title") }] : []), + { key: "capabilities", label: t("settings.globalCapabilities") }, ...(initialGoalId ? [{ key: "cadence" as const, label: t("cadence.title") }] : []), ], }, @@ -102,12 +105,12 @@ export function WorkspaceSettingsPage({ value: "zh-CN", }, ]; - const headings: Record = { + const headings: Record = { appearance: { title: t("settings.appearance"), }, capabilities: { - title: t("capabilities.title"), + title: t("settings.globalCapabilities"), }, cadence: { title: t("cadence.title"), @@ -118,9 +121,6 @@ export function WorkspaceSettingsPage({ lark: { title: "Lark", }, - machine: { - title: t("settings.globalCapabilities"), - }, provider: { title: t("settings.modelProvider"), }, @@ -130,7 +130,7 @@ export function WorkspaceSettingsPage({ }; const heading = headings[tab]; const selectedGoal = goals.find((item) => item.goalId === initialGoalId); - const goalSettingsTarget = (tab === "capabilities" || tab === "cadence") && initialGoalId + const goalSettingsTarget = tab === "cadence" && initialGoalId ? selectedGoal?.title || initialGoalId : null; @@ -161,7 +161,7 @@ export function WorkspaceSettingsPage({
-
+

{heading.title}

{goalSettingsTarget ? ( @@ -171,6 +171,23 @@ export function WorkspaceSettingsPage({ ) : null}
+ {tab === "capabilities" ?
+
+ {t("settings.capabilityScope")} + {(["machine", "goal"] as const).map((scope) => )} +
+ {capabilityScope === "goal" ? : null} +

{t(`settings.capabilityScope.${capabilityScope}Description`)}

+
: null}
{tab === "lark" ? ( : null} - {tab === "machine" ? : null} - {tab === "capabilities" ? ( + {tab === "capabilities" && capabilityScope === "machine" ? : null} + {tab === "capabilities" && capabilityScope === "goal" ? ( row.goalId === initialGoalId)} + goalId={capabilityGoalId} + notification={goalNotifications.find((row) => row.goalId === capabilityGoalId)} onChanged={onChanged} /> ) : null} diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md index 89481bdeb..8fd09be6a 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -86,6 +86,64 @@ Optional Lark must project the same choice and its own audience restrictions. Until these cases pass, this is the allocation design, not a runtime guarantee. See the [near-term launch route](loopx-overall-roadmap-v0.md#near-term-local-agent-product-and-launch). +## Settings ownership and scope + +Navigation follows the user's task; scope follows configuration ownership. +The steward remains a first-level **Steward** destination for its executor, +model/reasoning preference and host runtime grant. It is not a Goal member's +model setting and is not buried inside Capability Center merely because its +configuration is machine-owned. Reuse the same configuration components and +transaction owner behind these distinct destinations. + +Capability Center has one target selector: **Device defaults** or **One Goal**. +“Global” currently means the current device's configuration authority, not an +account-wide or cross-host synchronized setting. A remote host retains its own +credentials and defaults. Decide scope by the behavior below, not by whichever +editor or storage path happens to exist. + +| Configuration owner | Scope decision | Reason | +| --- | --- | --- | +| `manager_runtime` | Device only; Steward tab | The host grants tools to its steward. A Goal cannot widen that grant. | +| `steward_executor` | Device only; Steward tab | One steward channel owns its executor/model/effort preference. Goal worker allocation stays with that worker's binding. | +| `periodic_report` | Device default + Goal override | Reuse report style, timezone and schedule; allow project-specific cadence and a Goal-resolved route. A default route alias is not permission to deliver to another Goal's audience. | +| `change_quality_qualification` | Device default + Goal override | Reuse validation preferences while allowing the explicit project contract. Configuration does not waive repository or merge requirements. | +| `pull_request_review` | Device default + Goal override | Reuse CI-wait and queue-priority preferences; repositories may differ. Neither layer grants approval or merge authority. | +| `todo_replan_cadence` | Device default + Goal override | Reuse review frequency while allowing a Goal's work rhythm. This is completed-work review cadence, not the automation timer. | +| `multi_subagent` | Goal only | Child model, capacity, responsibility domains and delegation bindings apply to one Goal's team and resource boundary. | +| `peer_task_coordination` | Goal only | Coordinator identity must refer to a member of this Goal, not an inherited unrelated Agent. | +| `explore_harness`, `explore_graph` | Goal only | Exploration strategy and opt-in affect this Goal's planning and evidence graph. | +| `progress_review` | Goal only | Drift mode and threshold belong with this Goal's pinned acceptance contract revision. | +| `reward_memory` | Goal only | Private provider binding and enabled Agents are Goal-local; a global preference cannot spread that data binding. | +| `lark_kanban_heartbeat_sync` | Goal only | The target board and synchronization follow this Goal's binding and audience. | +| `lark_event_inbox` | Goal only; binding editor in Lark settings/CLI | Captured messages, session binding and cursor belong to this Goal. The capability card remains read-only; transport credentials remain host-owned. | +| `coordination_runtime_shadow` | Goal only | Experimental observation is explicitly scoped to this Goal's execution. | +| `local_authority_shadow` | Goal only; retired/read-only | Historical inspection does not become a new activation or device default. | + +These decisions retain the shipped scope contracts. Goal-only does not forbid a +future reusable preset: applying a preset would create a deliberate Goal-local +configuration, not live inheritance of identities, permissions, bindings or +experimental activation. A future split of reusable preferences from local +bindings must change their owning schema and actual consumers together before +advertising another scope. + +For dual-scope capabilities the precedence remains **explicit Goal value → +live device default → capability default**. A Goal override is a complete value, +not a field merge. Restoring inheritance removes the override via the existing +preview/apply/readback path. Read-only status is independent from scope: a +Goal-bound capability must still say Goal-only even when the current surface +cannot edit it. Catalog declarations, runtime resolution and the UI must agree; +unsupported-scope writes fail at the existing owner. + +Model-provider credentials, usage telemetry and appearance remain device-level +settings in their existing destinations. Agent assignment belongs to a Goal. +Automatic execution intervals keep their own Goal/Agent/automation hierarchy +and scheduler readback; the Capability Center does not create a second timer. + +Acceptance: the catalog covers the scope matrix above; unsupported writes are +rejected; packaged UI distinguishes all three scope classes and read-only Goal +bindings, preserves the independent Steward tab, and qualifies target switching, +preview/apply/readback and inheritance restoration without cross-target writes. + ## Problem LoopX already has the pieces of two different products: diff --git a/docs/development/design.md b/docs/development/design.md index 39ded6a56..c2c25bc5b 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -201,7 +201,9 @@ Do not mix marketing pills and application squares in the same control group. - Theme and language choices use the same two-column grid in every workspace theme. - Omit secondary descriptive lines under workspace page titles, navigation labels, and display choices. Preserve operational status, errors, and action outcomes. -- Capability navigation shows the localized name and a compact Goal or machine scope badge; omit the secondary internal identifier. Configuration forms use shared spacing and a full-width switch row; optional capability and field explanations live in a collapsed configuration-help section. Keep activation consequences and read-only restrictions visible. Goal and machine editors share the enable-row JSON entry point. Goal JSON accepts only registered editable fields and invalidates the previous preview whenever edited; applying still requires a new reviewed preview. +- Capability navigation shows the localized name; omit the secondary internal identifier. Show Goal/machine badges only in mixed-scope catalogs, not when the page already selects one scope. Configuration forms use shared spacing and a full-width switch row; optional capability and field explanations live in a collapsed configuration-help section. Keep activation consequences and read-only restrictions visible. Goal and machine editors share the enable-row JSON entry point. Goal JSON accepts only registered editable fields and invalidates the previous preview whenever edited; applying still requires a new reviewed preview. +- Present one Capability Center with an explicit device-default / single-Goal target. Goal entry points preselect their Goal; global entry points never silently choose one. Target changes discard the previous editor draft and preview, fetch the selected configuration, and preserve the existing revision-checked write owner. Distinguish configuration ownership from the currently selected target and editor permission: show device-only, Goal-only or device-default-with-Goal-override from the capability contract, even for read-only capabilities; keep writability in the existing editor status, and never list a machine-only capability in Goal scope. Show the affected scope and effective source before changes; do not turn storage boundaries into competing navigation entries. +- Keep Steward a first-level destination for the host steward executor/model/effort and runtime grant. Navigation follows purpose; configuration scope does not dictate navigation. See the [per-capability scope decisions](../architecture/rfcs/desktop-execution-frontends-v0.md#settings-ownership-and-scope). - Scope each settings catalog to its owner: Goal settings include only capabilities with Goal scope, including Goal-scoped read-only entries. Machine-only capabilities belong in machine or steward settings even when a shared API catalog also describes them. ### Forms diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index e61fbb63a..d400a5cb8 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -37,9 +37,9 @@ runtime authority/readback checks. ### Optional daily or weekly calendar -In Personal Workspace, open **Settings → Machine configuration → Periodic -reports** for machine defaults, or **Settings → Goal capabilities → Periodic -reports** for one Goal. Enable the subscription, choose its profile and Goal +In Personal Workspace, open **Settings → Capability Center**. Choose **Device +defaults** or **One Goal** and select the target Goal, then choose **Periodic +reports**. Enable the subscription, choose its profile and Goal Channel route, then turn on **Calendar reports**. Choose daily or weekly, the weekday for weekly reports, and a local time. The existing subscription timezone also owns the calendar timezone. Preview and apply use the same diff --git a/docs/reference/usage-ping.md b/docs/reference/usage-ping.md index fad6d6091..de193e9da 100644 --- a/docs/reference/usage-ping.md +++ b/docs/reference/usage-ping.md @@ -13,7 +13,7 @@ loopx usage-ping disable # stop all channels; delete local ID and pending co loopx usage-ping enable # explicitly allow all channels after reading the disclosure ``` -Settings → Capability Center exposes the same machine-wide switch and previews. +Settings → Capability Center → Device defaults exposes the same machine-wide switch and previews. Opening settings or running these commands never sends a measurement. Settings are owned by the TypeScript usage-statistics module, independently of a Goal's File/SQLite/PostgreSQL provider; Python and the browser adapt that same owner. diff --git a/docs/reference/usage-ping.zh-CN.md b/docs/reference/usage-ping.zh-CN.md index 34464cb57..3488be85f 100644 --- a/docs/reference/usage-ping.zh-CN.md +++ b/docs/reference/usage-ping.zh-CN.md @@ -11,7 +11,7 @@ loopx usage-ping disable # 关闭所有通道,删除本机 ID 和待发送 loopx usage-ping enable # 阅读告知后明确开启 ``` -设置 → 能力中心提供相同的整机开关与数据预览。打开设置和执行以上命令本身都不发送 +设置 → 能力中心 → 此设备默认提供相同的整机开关与数据预览。打开设置和执行以上命令本身都不发送 统计。TypeScript 统一拥有策略、状态和字段校验;Python、浏览器只是适配入口。 它不依赖 Goal 的 File/SQLite/PostgreSQL provider,Lark 没有另一套开关,也不能覆盖 机器所有者的选择。 diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 0399fb98f..01c71e00c 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -24,6 +24,7 @@ import { startServer, } from "./personal-workspace-browser/fixture.mjs"; import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; +import { capabilityScopeScenario } from "./personal-workspace-browser/capability-scope.mjs"; import { automationCadenceScenario } from "./personal-workspace-browser/automation-cadence.mjs"; import { teamEvidenceScenario } from "./personal-workspace-browser/team-evidence.mjs"; import { managedGoalResultsScenario } from "./personal-workspace-browser/managed-goal-results.mjs"; @@ -40,9 +41,9 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs"; import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs"; -import {stewardGroupTriggerScenario} from "./personal-workspace-browser/steward-group-trigger.mjs"; +import { stewardGroupTriggerScenario } from "./personal-workspace-browser/steward-group-trigger.mjs"; -const scenarioCatalog = [stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; +const scenarioCatalog = [capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, conversationReturnContinuityScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index daaa5f08c..f768c1c56 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -66,8 +66,9 @@ export const automationCadenceScenario = { 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("Multi Agent Projection", { exact: true }).waitFor(); + await page.getByRole("combobox", { name: "目标 Goal", exact: true }).waitFor(); await page.getByRole("button", { name: "能力中心" }).click(); + await page.getByRole("radio", { name: "此设备默认", exact: true }).check(); if (await target.count()) throw new Error("Machine settings retained a Goal-specific target"); await page.getByRole("button", { name: "自动执行间隔" }).click(); await target.getByText("Multi Agent Projection", { exact: true }).waitFor(); diff --git a/examples/personal-workspace-browser/capability-scope.mjs b/examples/personal-workspace-browser/capability-scope.mjs new file mode 100644 index 000000000..f90876ba6 --- /dev/null +++ b/examples/personal-workspace-browser/capability-scope.mjs @@ -0,0 +1,75 @@ +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const capabilityScopeScenario = { + id: "capability-scope", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { collectCoverage }); + const { api, page } = context; + const reads = []; + page.on("request", (request) => { + const parsed = new URL(request.url()); + if (parsed.pathname === "/api/chat/goal-configuration") reads.push(parsed.searchParams.get("goal_id")); + }); + try { + await page.getByRole("button", { name: "设置", exact: true }).click(); + const tabs = page.locator(".personal-settings-tabs"); + await tabs.getByRole("button", { name: "能力中心", exact: true }).click(); + if (await tabs.getByRole("button", { name: "Goal 能力", exact: true }).count()) throw new Error("Duplicate capability destinations remain"); + const defaults = page.getByRole("radio", { name: "此设备默认", exact: true }); + const goalScope = page.getByRole("radio", { name: "单个 Goal", exact: true }); + if (!await defaults.isChecked()) throw new Error("Global settings must start at device defaults"); + await page.getByRole("navigation", { name: "机器能力目录" }).waitFor(); + await tabs.getByRole("button", { name: "管家", exact: true }).click(); + await page.locator(".personal-capability-supported-scopes").filter({ hasText: "设备全局配置 · 不支持 Goal 覆盖" }).waitFor(); + await tabs.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("navigation", { name: "机器能力目录" }).waitFor(); + await page.screenshot({ path: resolve(outputDir, "capability-scope-defaults.png"), animations: "disabled" }); + await goalScope.check(); + const target = page.getByRole("combobox", { name: "目标 Goal", exact: true }); + if (await target.inputValue() !== "") throw new Error("Global entry silently chose a Goal"); + if (await page.locator(".personal-capability-detail").count()) throw new Error("An unselected Goal exposed an editor"); + await target.selectOption("product-release"); + await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor(); + await page.locator(".personal-capability-supported-scopes").filter({ hasText: "设备默认配置 · 支持 Goal 单独覆盖" }).waitFor(); + if (await page.getByRole("navigation", { name: "Goal 能力目录" }).getByRole("button", { name: /管家/ }).count()) throw new Error("Machine-only steward leaked into Goal catalog"); + const catalog = page.getByRole("navigation", { name: "Goal 能力目录" }); + await catalog.getByRole("button", { name: /飞书事件收件箱/ }).click(); + await page.locator(".personal-capability-supported-scopes").filter({ hasText: "仅单个 Goal 配置 · 无设备默认值" }).waitFor(); + await page.locator(".personal-capability-editor-status.is-read-only").waitFor(); + await catalog.getByRole("button", { name: /周期报告/ }).click(); + await page.getByLabel(/^报告 Profile/u).fill("unsaved-scope-test"); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await page.getByText("锁定 revision 的变更预览", { exact: true }).waitFor(); + if (api.goalConfigurationRequests.at(-1)?.goal_id !== "product-release") throw new Error("Preview targeted a different Goal"); + await target.selectOption("research-monitor"); + await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor(); + if (await page.locator(".personal-capability-preview").count()) throw new Error("A preview crossed Goal boundaries"); + if (await page.getByLabel(/^报告 Profile/u).inputValue() === "unsaved-scope-test") throw new Error("Draft crossed Goal boundaries"); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await page.getByText("锁定 revision 的变更预览", { exact: true }).waitFor(); + if (api.goalConfigurationRequests.at(-1)?.goal_id !== "research-monitor") throw new Error("Changed Goal was not used by preview"); + await defaults.check(); + await page.getByRole("navigation", { name: "机器能力目录" }).waitFor(); + if (await page.locator(".personal-capability-preview").count()) throw new Error("A Goal preview crossed into device defaults"); + await goalScope.check(); + await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor(); + if (await page.locator(".personal-capability-preview").count()) throw new Error("Returning revived a discarded preview"); + if (!reads.includes("product-release") || !reads.includes("research-monitor")) throw new Error("Target changes did not read their own configuration"); + if (api.goalConfigurationRequests.some((r) => r.phase === "apply") || api.machineConfigurationRequests.some((r) => r.phase === "apply")) throw new Error("Navigation wrote configuration"); + await page.screenshot({ path: resolve(outputDir, "capability-scope-goal.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await target.focus(); + await page.screenshot({ path: resolve(outputDir, "capability-scope-mobile.png"), animations: "disabled" }); + if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)) throw new Error("Scope controls overflow the narrow viewport"); + await page.setViewportSize({ width: 1512, height: 982 }); + await page.getByRole("button", { name: "返回工作区", exact: true }).click(); + await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.getByRole("button", { name: "Goal 设置", exact: true }).click(); + if (!await goalScope.isChecked() || await target.inputValue() !== "product-release") throw new Error("Goal settings entry lost its target"); + if (context.errors.length) throw new Error(context.errors.join(" | ")); + return { coverageEntries: await context.close(), note: "One capability destination; explicit device/Goal scope; fresh target reads; no cross-scope drafts or writes; Goal entry preserved; desktop/mobile verified." }; + } catch (error) { await context.close(); throw error; } + }, +}; diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index a5926ab02..0fccc0eba 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1002,7 +1002,7 @@ export const typedActionsScenario = { await page.locator(".personal-object-list").first().waitFor({ state: "visible" }); if (await page.locator(".personal-task-capability-callout").count()) throw new Error("Goal capability settings still consume a full-width Tasks row"); await page.getByRole("button", { name: "Goal 设置", exact: true }).click(); - await page.getByRole("heading", { level: 1, name: "Goal 能力", exact: true }).waitFor({ state: "visible" }); + await page.getByRole("heading", { level: 1, name: "能力中心", exact: true }).waitFor({ state: "visible" }); if (await page.locator(".personal-workspace-shell:visible").count()) throw new Error("Unified Goal capability action did not open the Settings surface"); await page.getByRole("heading", { level: 2, name: /^周期报告/ }).waitFor({ state: "visible" }); const goalCapabilityOrder = await page.locator(".personal-capability-list button strong").allTextContents(); @@ -1194,6 +1194,7 @@ export const typedActionsScenario = { api.failNextMachineInspection = true; await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("radio", { name: "此设备默认", exact: true }).check(); await page.getByRole("heading", { level: 1, name: "能力中心", exact: true }).waitFor({ state: "visible" }); const loadError = page.getByRole("alert").filter({ hasText: "无法读取机器配置" }); await loadError.waitFor({ state: "visible" }); @@ -1210,7 +1211,7 @@ export const typedActionsScenario = { const capabilityOverlap = await stackedBlocks(); if (capabilityOverlap) throw new Error(`Global capability category ${capabilityOverlap}`); const machineCatalog = page.getByRole("navigation", { name: "机器能力目录" }); - const firstMachineCapability = machineCatalog.getByRole("button").filter({ hasText: "机器" }).first(); + const firstMachineCapability = machineCatalog.getByRole("button").first(); await firstMachineCapability.waitFor({ state: "visible" }); const initialMachineTitle = await firstMachineCapability.locator("strong").innerText(); await page.getByRole("heading", { level: 2, name: initialMachineTitle, exact: true }).waitFor({ state: "visible" }); @@ -1297,7 +1298,7 @@ export const typedActionsScenario = { } await page.getByText("Enabled means automatic delivery at validated stage boundaries", { exact: true }).waitFor({ state: "visible" }); await page.screenshot({ path: resolve(outputDir, "machine-capability-en.png"), fullPage: false, animations: "disabled" }); - await page.getByRole("button", { name: /Goal capabilities/ }).click(); + await page.getByRole("radio", { name: "One Goal", exact: true }).check(); await page.getByRole("button", { name: /Adaptive child capacity/ }).click(); await page.getByRole("heading", { level: 2, name: "Adaptive child capacity", exact: true }).waitFor({ state: "visible" }); for (const label of [/^Enabled$/u, /^Child model/u, /^Child reasoning effort/u, /^Maximum children/u, /^Allowed responsibility domains/u]) { @@ -1307,6 +1308,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /Language/ }).click(); await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("radio", { name: "此设备默认", exact: true }).check(); await page.locator(".personal-settings-body").evaluate((element) => element.scrollTo({ top: 0 })); await page.screenshot({ path: resolve(outputDir, "machine-capability-zh-cn.png"), fullPage: false, animations: "disabled" }); // Steward owns a first-level destination with just model/executor and @@ -1386,6 +1388,7 @@ export const typedActionsScenario = { api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["periodic_report"]; await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("radio", { name: "此设备默认", exact: true }).check(); await invalidRepair.waitFor({ state: "visible" }); await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor({ state: "visible" }); await page.getByRole("button", { name: "预览变更", exact: true }).click(); diff --git a/tests/capabilities/test_capability_configuration_ui.py b/tests/capabilities/test_capability_configuration_ui.py index c708badd4..375949dda 100644 --- a/tests/capabilities/test_capability_configuration_ui.py +++ b/tests/capabilities/test_capability_configuration_ui.py @@ -352,3 +352,43 @@ def test_resolution_rejects_values_for_unsupported_scopes() -> None: "periodic_report", goal_override=True, ) + + +@pytest.mark.parametrize( + ("capability_id", "scopes"), + [ + ("manager_runtime", ["machine"]), + ("steward_executor", ["machine"]), + ("periodic_report", ["machine", "goal"]), + ("change_quality_qualification", ["machine", "goal"]), + ("pull_request_review", ["machine", "goal"]), + ("todo_replan_cadence", ["machine", "goal"]), + ("multi_subagent", ["goal"]), + ("peer_task_coordination", ["goal"]), + ("explore_harness", ["goal"]), + ("explore_graph", ["goal"]), + ("progress_review", ["goal"]), + ("reward_memory", ["goal"]), + ("lark_kanban_heartbeat_sync", ["goal"]), + ("lark_event_inbox", ["goal"]), + ("coordination_runtime_shadow", ["goal"]), + ("local_authority_shadow", ["goal"]), + ], +) +def test_capability_scope_matches_product_ownership(capability_id, scopes) -> None: + """Host authority, reusable defaults and Goal bindings stay distinct.""" + + catalog = build_capability_configuration_catalog( + machine_namespaces=build_builtin_machine_configuration_registry().public_catalog()[ + "namespaces" + ], + goal_features=build_configuration_capability_descriptors(), + ) + descriptor = next(c for c in catalog["capabilities"] if c["capability_id"] == capability_id) + assert descriptor["available_scopes"] == scopes + assert descriptor["configuration_editor"]["supported_scopes"] == scopes + assert set(descriptor["configuration_editor"]["writable_scopes"]) <= set(scopes) + for scope, keyword in (("machine", "machine_default"), ("goal", "goal_override")): + if scope not in scopes: + with pytest.raises(ValueError, match="does not support"): + resolve_capability_configuration(capability_id, **{keyword: {}})