diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx index b6733b302e..eeea715829 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx @@ -54,11 +54,14 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone ); } const numeric = field.input_kind === "number"; + const modelSuggestions = field.key === "executor_model" + ? ["gpt-6-sol", "gpt-6-luna", "gpt-6-astra"] : []; return ( ); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 9519eee396..72b1e78fae 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -13,11 +13,11 @@ type FieldCopy = Record> = { en: { manager_runtime: { - displayName: "Manager runtime", + displayName: "Runtime", description: "Selects the persistent host-tool profile used by owner manager conversations.", }, steward_executor: { - displayName: "Steward executor", + displayName: "Model and executor", description: "Guides the steward executor, model, and selection boundary for this machine. A pinned route blocks substitution; a flexible pool permits only authorized fallback.", }, todo_replan_cadence: { displayName: "Goal review cadence", description: "Configures the Goal review cadence." }, @@ -77,11 +77,11 @@ const capabilityCopy: Record> = { }, "zh-CN": { manager_runtime: { - displayName: "管家 Runtime", + displayName: "运行环境", description: "选择管家会话持续生效的宿主工具模式。", }, steward_executor: { - displayName: "管家执行器", + displayName: "模型与执行器", description: "配置本机管家的执行器、模型与选择边界;锁定路径禁止替代,灵活池只允许在已授权范围内回退。", }, todo_replan_cadence: { displayName: "Goal 复核周期", description: "配置 Goal 的复核周期。" }, 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 4564923385..cc3d37f608 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx @@ -78,6 +78,7 @@ export function CapabilityCatalogNavigation({ onSelect, scope, selectedCapabilityId, + showScope = true, t, }: Readonly<{ capabilities: CapabilityDescriptor[]; @@ -85,6 +86,7 @@ export function CapabilityCatalogNavigation({ onSelect: (capabilityId: string) => void; scope: "goal" | "machine"; selectedCapabilityId: string; + showScope?: boolean; t: WorkspaceTranslate; }>) { return ( @@ -101,9 +103,9 @@ export function CapabilityCatalogNavigation({ {capability.display_name} - {t(capability.available_scopes.includes(scope) + {showScope ? {t(capability.available_scopes.includes(scope) ? scope === "goal" ? "capabilities.goalScope" : "capabilities.machineScope" - : scope === "machine" ? "capabilities.goalScope" : "capabilities.machineScope")} + : scope === "machine" ? "capabilities.goalScope" : "capabilities.machineScope")} : null} ); })} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 8d269202d5..e42f625573 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -971,9 +971,12 @@ const en = { "settings.eyebrow": "Workspace preferences", "settings.general": "General", "settings.goalConnections": "Goal connections", + "settings.agentGroup": "Agents and models", + "settings.workspaceGroup": "Workspace", + "settings.steward": "Steward", "settings.language": "Language", "settings.modelProvider": "Model provider", - "settings.globalCapabilities": "Global capabilities", + "settings.globalCapabilities": "Capability Center", "settings.languageDescription": "Choose the language used by the LoopX desktop workspace.", "settings.languageEnglishDescription": "Use English for navigation, settings, and workspace controls.", "settings.languageEnglish": "English", @@ -2082,9 +2085,12 @@ const zhCN: Record = { "settings.eyebrow": "工作区偏好", "settings.general": "通用", "settings.goalConnections": "Goal 连接", + "settings.agentGroup": "Agent 与模型", + "settings.workspaceGroup": "工作区", + "settings.steward": "管家", "settings.language": "语言", "settings.modelProvider": "模型 Provider 配置", - "settings.globalCapabilities": "全局能力配置", + "settings.globalCapabilities": "能力中心", "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 5b96456bf7..78e6a5d7fe 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 @@ -101,7 +101,7 @@ function shortRevision(value: string | undefined) { return value.replace(/^sha256:/, "").slice(0, 12); } -export function MachineConfigurationSettings() { +export function MachineConfigurationSettings({ section }: { section: "steward" | "other" }) { const { locale, t } = useWorkspaceI18n(); const [inspection, setInspection] = useState(null); const [selectedCapabilityId, setSelectedCapabilityId] = useState(""); @@ -117,14 +117,21 @@ export function MachineConfigurationSettings() { const [notice, setNotice] = useState(null); const capabilities = useMemo(() => orderCapabilitiesForPresentation( - inspection?.capability_catalog.capabilities ?? [], locale, - ), [inspection, locale]); + (inspection?.capability_catalog.capabilities ?? []).filter((capability) => + capability.available_scopes.includes("machine") + && (section === "steward" + ? capability.capability_id === "steward_executor" || capability.capability_id === "manager_runtime" + : capability.capability_id !== "steward_executor" && capability.capability_id !== "manager_runtime")), + locale, + ), [inspection, locale, section]); const invalidNamespace = inspection?.invalid_namespaces[0]; const selectedRaw = capabilities.find( (capability) => capability.capability_id === selectedCapabilityId, ) ?? (invalidNamespace ? capabilities.find( (capability) => capability.machine_namespace === invalidNamespace, - ) : undefined) ?? capabilities.find((capability) => canEditCapability(capability, "machine")) ?? capabilities[0]; + ) : undefined) ?? (section === "steward" + ? capabilities.find((capability) => capability.capability_id === "steward_executor") + : undefined) ?? capabilities.find((capability) => canEditCapability(capability, "machine")) ?? capabilities[0]; const selected = selectedRaw ? localizeCapability(selectedRaw, locale) : undefined; const selectedCurrent = currentConfiguration(inspection, selected); const configured = Boolean(selected?.machine_namespace && selectedCurrent); @@ -325,7 +332,7 @@ export function MachineConfigurationSettings() { ) : null}
- +
) : null} + {selected.capability_id === "steward_executor" ? ( +
+ +
{locale === "zh-CN" ? "管家模型与思考深度" : "Steward model and reasoning"}

{locale === "zh-CN" + ? "这里设置本机管家新会话的默认模型和思考深度。已有会话可能继续使用原来的分配;配置成功不代表正在运行的会话已切换。" + : "Choose the model and reasoning effort for new steward sessions on this machine. Existing sessions may retain their earlier allocation; saving a default does not switch a running session."}

+
+ ) : null} + {selected.capability_id === "pull_request_review" ? (
@@ -377,7 +393,9 @@ export function MachineConfigurationSettings() { {editorMode === "guided" ? (
- changeMode("json")} type="button">{t("machine.editJson")}} /> {!editorValid ?

{t("machine.requiredFields")}

: null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 4f25e30864..b194167595 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -357,7 +357,7 @@ export type WorkspaceDrawerSelection = | { item: WorkspaceRun; kind: "run" } | { item: WorkspaceOutput; kind: "output" } | { item: WorkspaceActionPreview; kind: "proposal" } - | { goalId?: string; kind: "settings"; tab?: "appearance" | "capabilities" | "language" | "lark" | "machine" } + | { goalId?: string; kind: "settings"; tab?: "appearance" | "capabilities" | "language" | "lark" | "machine" | "steward" } | { item: WorkspaceSchedule; kind: "schedule"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 2b9e9ac4f2..80ea58cf9a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1909,7 +1909,7 @@ export function PersonalWorkspacePage({ goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} initialGoalId={selection?.kind === "settings" ? selection.goalId ?? selectedGoalId : selectedGoalId} - initialTab={selection?.kind === "settings" ? selection.tab ?? "lark" : "lark"} + initialTab={selection?.kind === "settings" ? selection.tab ?? (selectedGoalId ? "lark" : "steward") : "steward"} onChanged={() => void refreshSettingsState()} onClose={closeSettings} onThemeChange={updateTheme} 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 60f94c348e..d759b8335e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -422,7 +422,9 @@ .personal-settings-goal-target { display: inline-flex; align-items: center; gap: 7px; max-width: min(100%, 360px); min-width: 0; padding: 6px 10px; border: 1px solid var(--pw-line-strong); border-radius: 7px; background: var(--pw-card); font-size: 12px; line-height: 1.35; } .personal-settings-goal-target > span { flex: none; color: var(--pw-muted); } .personal-settings-goal-target > strong { overflow: hidden; min-width: 0; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } -.personal-settings-tabs { display: grid; gap: 3px; } +.personal-settings-tabs { display: grid; align-content: start; gap: 18px; } +.personal-settings-tab-group { display: grid; gap: 3px; min-width: 0; } +.personal-settings-tab-group-label { padding: 0 10px 7px; color: var(--pw-faint); font-size: 10px; font-weight: 700; letter-spacing: .06em; } .personal-settings-tabs button { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 9px; width: 100%; min-height: 54px; padding: 8px 10px; border: 0; border-radius: 11px; background: transparent; color: var(--pw-muted); cursor: pointer; text-align: left; } .personal-settings-tabs button:hover { background: rgb(255 255 255 / 65%); color: var(--pw-text); } .personal-settings-tabs button[aria-current="page"] { background: #fff; color: var(--pw-text); box-shadow: 0 1px 4px rgb(30 28 20 / 10%); } @@ -1316,7 +1318,10 @@ .personal-settings-page { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } .personal-settings-sidebar { position: static; gap: 10px; height: auto; max-height: 30dvh; overflow: auto; padding: 12px 14px; border-right: 0; border-bottom: 1px solid var(--pw-line); } .personal-settings-title { display: none; } - .personal-settings-tabs { display: flex; overflow-x: auto; } + .personal-settings-tabs { display: flex; gap: 14px; overflow-x: auto; } + .personal-settings-tab-group { display: flex; flex: 0 0 auto; gap: 3px; } + .personal-settings-tab-group + .personal-settings-tab-group { padding-left: 14px; border-left: 1px solid var(--pw-line); } + .personal-settings-tab-group-label { display: none; } .personal-settings-tabs button { flex: 0 0 180px; } .personal-settings-body { padding: 20px 14px; } .personal-settings-header { align-items: center; gap: 14px; } 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 125a833b3e..c00398bcaf 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 @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { ArrowLeft, Check, Clock3, KeyRound, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; +import { ArrowLeft, Bot, Check, Clock3, KeyRound, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; import type { WorkspaceLocale } from "./i18n"; import { useWorkspaceI18n } from "./i18n"; @@ -11,7 +11,7 @@ import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; -type WorkspaceSettingsTab = "provider" | "machine" | "capabilities" | "cadence" | "lark" | "appearance" | "language"; +type WorkspaceSettingsTab = "steward" | "provider" | "machine" | "capabilities" | "cadence" | "lark" | "appearance" | "language"; const tabIcons: Record = { appearance: Palette, @@ -21,6 +21,7 @@ const tabIcons: Record = { lark: Settings2, machine: ServerCog, provider: KeyRound, + steward: Bot, }; export function WorkspaceSettingsPage({ @@ -67,18 +68,29 @@ export function WorkspaceSettingsPage({ observer.observe(navigation); return () => observer.disconnect(); }, [tab]); - const tabs: Array<{ key: WorkspaceSettingsTab; label: string }> = [ - ...(initialGoalId ? [{ key: "capabilities" as const, label: t("capabilities.title") }] : []), - ...(initialGoalId ? [{ key: "cadence" as const, label: t("cadence.title") }] : []), - // The model provider is one machine decision (which endpoint and key the - // operator credential holds); the capability catalog is another (which - // 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") }, - { key: "lark", label: "Lark" }, - { key: "appearance", label: t("settings.appearance") }, - { key: "language", label: t("settings.language") }, + const tabGroups: Array<{ label: string; tabs: Array<{ key: WorkspaceSettingsTab; label: string }> }> = [ + { + label: t("settings.agentGroup"), + tabs: [ + { key: "steward", label: t("settings.steward") }, + // The model provider is one machine decision (which endpoint and key the + // operator credential holds); the capability catalog is another (which + // 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") }] : []), + ...(initialGoalId ? [{ key: "cadence" as const, label: t("cadence.title") }] : []), + ], + }, + { + label: t("settings.workspaceGroup"), + tabs: [ + { key: "lark", label: "Lark" }, + { key: "appearance", label: t("settings.appearance") }, + { key: "language", label: t("settings.language") }, + ], + }, ]; const localeOptions: Array<{ label: string; value: WorkspaceLocale }> = [ { @@ -112,6 +124,9 @@ export function WorkspaceSettingsPage({ provider: { title: t("settings.modelProvider"), }, + steward: { + title: t("settings.steward"), + }, }; const heading = headings[tab]; const selectedGoal = goals.find((item) => item.goalId === initialGoalId); @@ -130,17 +145,18 @@ export function WorkspaceSettingsPage({ {t("settings.title")}
@@ -173,7 +189,8 @@ export function WorkspaceSettingsPage({ ) : null} - {tab === "machine" ? : null} + {tab === "steward" ? : null} + {tab === "machine" ? : null} {tab === "capabilities" ? ( scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index 965e5479d6..4b606541a5 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -67,7 +67,7 @@ export const automationCadenceScenario = { 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 page.getByRole("button", { name: "全局能力配置" }).click(); + 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(); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 797d05efa4..e58fc49404 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1140,6 +1140,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (url.pathname === "/api/chat/machine-configuration/apply" && request.method() === "POST") { const body = request.postDataJSON(); state.machineConfigurationRequests.push({ phase: "apply", ...body }); + machineNamespaces[body.namespace] = body.namespace_configuration; state.machineInspectionStatus = "configured"; state.invalidMachineNamespaces = []; await route.fulfill({ contentType: "application/json", json: { diff --git a/examples/personal-workspace-browser/steward-model-settings.mjs b/examples/personal-workspace-browser/steward-model-settings.mjs new file mode 100644 index 0000000000..e9104f2f74 --- /dev/null +++ b/examples/personal-workspace-browser/steward-model-settings.mjs @@ -0,0 +1,62 @@ +import { resolve } from "node:path"; + +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const stewardModelSettingsScenario = { + id: "steward-model-settings", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { collectCoverage }); + const { api, page } = context; + try { + await page.getByRole("button", { name: "设置", exact: true }).click(); + const settingsTabs = page.locator(".personal-settings-tabs"); + const stewardTab = settingsTabs.getByRole("button", { name: "管家", exact: true }); + if (await stewardTab.getAttribute("aria-current") !== "page") { + throw new Error("Steward must be the primary settings destination"); + } + const capabilityList = page.locator(".personal-capability-list"); + await capabilityList.getByRole("button", { name: "运行环境" }).waitFor(); + if (await capabilityList.getByRole("button").count() !== 2) { + throw new Error("Steward settings should only contain model/executor and runtime"); + } + await settingsTabs.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.locator(".personal-capability-detail").waitFor(); + if (await capabilityList.getByRole("button", { name: "模型与执行器" }).count() + || await capabilityList.getByRole("button", { name: "运行环境" }).count() + || await capabilityList.getByRole("button", { name: "探索图谱" }).count()) { + throw new Error("Other machine settings must exclude steward and Goal-only capabilities"); + } + await stewardTab.click(); + const detail = page.locator(".personal-capability-detail"); + await detail.getByText("管家模型与思考深度").waitFor(); + await page.screenshot({ path: resolve(outputDir, "steward-model-settings.png"), fullPage: false, animations: "disabled" }); + await detail.getByLabel("模型").fill("gpt-6-sol"); + await detail.getByLabel("推理档位").selectOption("xhigh"); + await detail.getByRole("button", { name: "预览变更" }).click(); + await detail.getByRole("button", { name: "应用已审阅预览" }).click(); + const applied = api.machineConfigurationRequests.find((item) => item.phase === "apply"); + if (applied?.namespace !== "steward_executor" + || applied?.namespace_configuration?.executor_model !== "gpt-6-sol" + || applied?.namespace_configuration?.executor_reasoning_effort !== "xhigh") { + throw new Error("Steward settings did not apply the selected model and effort"); + } + await detail.getByLabel("模型").waitFor(); + if (await detail.getByLabel("模型").inputValue() !== "gpt-6-sol" + || await detail.getByLabel("推理档位").inputValue() !== "xhigh") { + throw new Error("Steward model and effort were not read back after apply"); + } + await page.setViewportSize({ width: 390, height: 844 }); + await stewardTab.waitFor({ state: "visible" }); + if (await stewardTab.getAttribute("aria-current") !== "page") { + throw new Error("Steward section was lost in the narrow settings navigation"); + } + await page.screenshot({ path: resolve(outputDir, "steward-model-settings-mobile.png"), fullPage: false, animations: "disabled" }); + if (context.errors.length) throw new Error(context.errors.join(" | ")); + return { coverageEntries: await context.close(), note: "Manager settings directly select and read back Sol xhigh" }; + } 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 f82425f6f4..19a501c2f5 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1128,8 +1128,8 @@ export const typedActionsScenario = { if (providerOverlap) throw new Error(`Model provider category ${providerOverlap}`); await page.screenshot({ path: resolve(outputDir, "model-provider-settings-zh-cn.png"), fullPage: false, animations: "disabled" }); - await page.getByRole("button", { name: /全局能力配置/ }).click(); - await page.getByRole("heading", { level: 1, name: "全局能力配置", exact: true }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "能力中心", exact: true }).click(); + await page.getByRole("heading", { level: 1, name: "能力中心", exact: true }).waitFor({ state: "visible" }); // The catalog workbench mounts after its inspection resolves, so the // category's contents are asserted only once the workbench itself exists. await page.locator(".personal-capability-layout").waitFor({ state: "visible" }); @@ -1147,24 +1147,17 @@ export const typedActionsScenario = { throw new Error("Initial machine selection must follow the visible catalog order, not the API source order"); } if (await page.locator(".personal-capability-editor-status").count()) throw new Error("Editable machine settings must not show internal editor-contract notices"); - // Two capabilities are machine-only: the manager runtime profile and the - // steward channel's executor. Every other catalog entry is Goal-scoped. - if (await machineCatalog.getByRole("button").count() !== goalCapabilityCatalog().length + 2) { - throw new Error("Machine settings did not combine machine-only and Goal capabilities in the shared catalog"); + // This catalog contains only machine-scoped capabilities outside the + // steward section. Goal-only entries belong in Goal settings. + if (await machineCatalog.getByRole("button").count() !== 3) { + throw new Error("Other machine settings must exclude steward and Goal-only capabilities"); } - await machineCatalog.getByRole("button", { name: /^管家 Runtime/ }).click(); - await page.getByLabel(/^运行模式/u).waitFor({ state: "visible" }); - await page.locator(".personal-capability-help > summary").click(); - await page.getByText(/受保护操作仍单独校验/u).waitFor({ state: "visible" }); - await page.screenshot({ path: resolve(outputDir, "manager-runtime-machine-profile.png"), fullPage: false, animations: "disabled" }); const requestsBeforeReadOnly = api.machineConfigurationRequests.length; - await machineCatalog.getByRole("button", { name: /^自适应子 Agent 容量/ }).click(); - await page.getByText(/此能力目前仅支持 Goal 级配置/u).waitFor({ state: "visible" }); - if (await page.getByRole("button", { name: "预览变更", exact: true }).count() - || await page.locator("#machine-configuration-json").count() - || await page.getByLabel(/^启用$/u).count() + if (await machineCatalog.getByRole("button", { name: /^自适应子 Agent 容量/ }).count() + || await machineCatalog.getByRole("button", { name: /^运行环境/ }).count() + || await machineCatalog.getByRole("button", { name: /^模型与执行器/ }).count() || api.machineConfigurationRequests.length !== requestsBeforeReadOnly) { - throw new Error("Goal-only capability exposed a machine mutation path"); + throw new Error("Other machine settings mixed Goal-only or steward controls into the catalog"); } await machineCatalog.getByRole("button", { name: /^Goal 复核周期/ }).click(); await page.getByLabel(/^两次 Goal 复核间的已完成 Todo 数/u).waitFor({ state: "visible" }); @@ -1216,7 +1209,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /语言/ }).click(); await page.getByRole("radio", { name: /English/ }).click(); - await page.getByRole("button", { name: /Global capabilities/ }).click(); + await page.getByRole("button", { name: /Capability Center/ }).click(); await page.getByRole("heading", { level: 2, name: "Periodic reports", exact: true }).waitFor({ state: "visible" }); const rawValues = page.locator(".personal-capability-raw-values"); if (await rawValues.getAttribute("open") !== null) throw new Error("Raw JSON must be collapsed by default"); @@ -1242,14 +1235,13 @@ export const typedActionsScenario = { await page.screenshot({ path: resolve(outputDir, "goal-subagent-capability-en.png"), fullPage: false, animations: "disabled" }); await page.getByRole("button", { name: /Language/ }).click(); await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); - await page.getByRole("button", { name: /全局能力配置/ }).click(); + await page.getByRole("button", { name: "能力中心", exact: true }).click(); 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" }); - // The steward's own executor is a machine setting like any other: the - // operator picks it in the form, and the exact reviewed revision carries - // the choice into the same namespaced store. - await page.getByRole("button", { name: /管家执行器/ }).click(); - await page.getByRole("heading", { level: 2, name: "管家执行器", exact: true }).waitFor({ state: "visible" }); + // Steward owns a first-level destination with just model/executor and + // runtime. The exact reviewed revision still uses the machine store. + await page.locator(".personal-settings-tabs").getByRole("button", { name: "管家", exact: true }).click(); + await page.getByRole("heading", { level: 2, name: "模型与执行器", exact: true }).waitFor({ state: "visible" }); const stewardFields = page.locator(".personal-capability-fields"); // The selects carry their option text inside the same label, so they are // matched by prefix rather than by an exact label string. @@ -1257,7 +1249,9 @@ export const typedActionsScenario = { await stewardFields.getByLabel(/^模型/u).waitFor({ state: "visible" }); await stewardFields.getByLabel(/^推理档位/u).waitFor({ state: "visible" }); await stewardFields.getByLabel(/^首选管家执行器/u).selectOption("dsh"); - await stewardFields.getByLabel(/^灵活池可用执行器/u).waitFor({ state: "visible" }); + if (await stewardFields.getByLabel(/^灵活池可用执行器/u).count()) { + throw new Error("Preferred steward routing must not show the flexible fallback pool"); + } await stewardFields.getByLabel(/^模型/u).fill("deepseek-v4-flash"); await stewardFields.getByLabel(/^推理档位/u).selectOption("high"); await page.screenshot({ path: resolve(outputDir, "machine-steward-executor-zh-cn.png"), fullPage: false, animations: "disabled" }); @@ -1280,6 +1274,11 @@ export const typedActionsScenario = { if (stewardApply?.expected_plan_revision !== "sha256:machine-plan") { throw new Error("The steward executor apply lost its reviewed plan revision"); } + await page.locator(".personal-capability-list").getByRole("button", { name: "运行环境", exact: true }).click(); + await page.getByLabel(/^运行模式/u).waitFor({ state: "visible" }); + await page.locator(".personal-capability-help > summary").click(); + await page.getByText(/受保护操作仍单独校验/u).waitFor({ state: "visible" }); + await page.screenshot({ path: resolve(outputDir, "manager-runtime-machine-profile.png"), fullPage: false, animations: "disabled" }); const settingsViewport = page.viewportSize(); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(200); @@ -1292,10 +1291,10 @@ export const typedActionsScenario = { api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["manager_runtime"]; - await page.getByRole("button", { name: /全局能力配置/ }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "管家", exact: true }).click(); const invalidRepair = page.getByTestId("machine-invalid-repair"); await invalidRepair.waitFor({ state: "visible" }); - await page.getByRole("heading", { level: 2, name: "管家 Runtime", exact: true }).waitFor({ state: "visible" }); + await page.getByRole("heading", { level: 2, name: "运行环境", exact: true }).waitFor({ state: "visible" }); await page.getByRole("button", { name: "预览变更", exact: true }).click(); const managerRepairPreview = api.machineConfigurationRequests.findLast( (item) => item.phase === "preview" && item.namespace === "manager_runtime", @@ -1315,7 +1314,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /Lark/ }).click(); api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["periodic_report"]; - await page.getByRole("button", { name: /全局能力配置/ }).click(); + await page.getByRole("button", { name: "能力中心", exact: true }).click(); 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/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 3c0e77c414..cc41b5d8f7 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -15,6 +15,7 @@ from typing import Any from ...chat_manager import MANAGER_AGENT_OBJECTIVE +from ...file_lock import exclusive_file_lock from .manager_routing import ( has_manager_binding, invalid_manager_authority_result, @@ -870,7 +871,13 @@ def process_lark_goal_topic_event( provider_runner: Any | None = None, proposal_deliverer: ProposalDeliverer | None = None, ) -> dict[str, Any]: - """Route, persist, answer, reply, and ACK one bound Topic event.""" + """Single-flight an addressed manager message through answer, reply and ACK. + + The inbox and delivery receipt make sequential retries safe, but two + listeners can otherwise both observe an absent receipt and generate two + different answers before either writes it. The lock is per source message + and spans the entire effect, including provider readback and inbox ACK. + """ decision = decide_lark_topic_event( target_payload=target_payload, @@ -878,6 +885,61 @@ def process_lark_goal_topic_event( event=event, runtime_root=runtime_root, ) + route = decision.get("route") + if ( + isinstance(route, Mapping) + and route.get("conversation_kind") == "manager" + and route.get("authority_mode") == ManagerAuthorityMode.TURN_AUTHORIZED.value + ): + message_id = str(route.get("message_id") or "") + if not MESSAGE_ID_PATTERN.fullmatch(message_id): + raise ValueError("manager route has an invalid message id") + lock_dir = Path(runtime_root).expanduser().resolve() / ".loopx/locks/lark-manager" + lock_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(lock_dir, 0o700) + with exclusive_file_lock( + lock_dir / message_id, + timeout_seconds=960, + operation="lark_manager_message", + ): + return _process_lark_goal_topic_event( + target_payload=target_payload, + event=event, + runtime_root=runtime_root, + goal_contexts=goal_contexts, + answer=answer, + reply_runner=reply_runner, + provider_runner=provider_runner, + proposal_deliverer=proposal_deliverer, + decision=decision, + ) + return _process_lark_goal_topic_event( + target_payload=target_payload, + event=event, + runtime_root=runtime_root, + goal_contexts=goal_contexts, + answer=answer, + reply_runner=reply_runner, + provider_runner=provider_runner, + proposal_deliverer=proposal_deliverer, + decision=decision, + ) + + +def _process_lark_goal_topic_event( + *, + target_payload: Mapping[str, Any], + event: Mapping[str, Any], + runtime_root: str | Path, + goal_contexts: Mapping[str, Mapping[str, Any]] | None, + answer: Answer, + reply_runner: CommandRunner, + provider_runner: object | None, + proposal_deliverer: ProposalDeliverer | None, + decision: Mapping[str, Any], +) -> dict[str, object]: + """Persist, answer, reply, and ACK the already-routed Topic event.""" + route = decision.get("route") if route is None: return { diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index a01596bb4b..0c3d1da673 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -5,6 +5,7 @@ import re import subprocess import threading +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError from collections.abc import Mapping from datetime import UTC, datetime from pathlib import Path @@ -2502,6 +2503,70 @@ def answer(route, text): assert stages == before +def test_concurrent_manager_delivery_answers_one_source_message_once(tmp_path, monkeypatch): + from loopx.extensions.lark import goal_topic_runtime as runtime + + target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" + _seed_legacy_topic(target_path, binding_path) + original_decide = runtime.decide_lark_topic_event + + def manager_decision(**kwargs): + result = original_decide(**kwargs) + result["route"] = { + **result["route"], + "conversation_kind": "manager", + "authority_mode": "turn_authorized", + "ingress_mode": "session_queue", + } + return result + + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) + monkeypatch.setattr( + runtime, "ensure_lark_event_inbox_received_reaction", + lambda **_kwargs: {"ok": True, "status": "already_received"}, + ) + entered, release = threading.Event(), threading.Event() + answers: list[str] = [] + state: dict[str, Any] = {} + + def answer(_route, _text): + answers.append("answer") + entered.set() + assert release.wait(5) + return "One verified answer." + + kwargs = dict( + target_payload=read_goal_channel_targets(target_path), + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, + event={ + "event_id": "evt_one_source", "message_id": "om_one_source", + "chat_id": "oc_public_fixture", "root_id": "om_topic_alpha", + "create_time": "2026-08-14T21:00:00Z", "content": "@linkmacbot question", + "sender_type": "user", "sender_id": "ou_owner_fixture", + }, + runtime_root=tmp_path / "runtime", + answer=answer, + reply_runner=_reply_runner(state), + ) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(runtime.process_lark_goal_topic_event, **kwargs) + assert entered.wait(5) + second = pool.submit(runtime.process_lark_goal_topic_event, **kwargs) + try: + with pytest.raises(FutureTimeoutError): + second.result(timeout=0.1) + finally: + release.set() + assert first.result(timeout=5)["status"] == "replied_and_acknowledged" + assert second.result(timeout=5)["status"] == "already_acknowledged" + assert answers == ["answer"] + sends = [ + call for call in state["calls"] + if "+messages-reply" in call and "--dry-run" not in call + ] + assert len(sends) == 1 + + @pytest.mark.parametrize("error_code,label", [ ("cyber_policy", "安全策略拦截"), ("rate_limit_exceeded", "请求频率限制"),