diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 3ef44b67d0..e2ba346c63 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1486,8 +1486,20 @@ function PersonalGoalHome({ const defaultAgentId = discoveredAgents.find((agent) => agent.label === "Codex" && agent.available)?.agentId ?? discoveredAgents.find((agent) => agent.available)?.agentId ?? "status-only"; + // The manager channel answers on the executor this machine declares for the + // steward, and the client is expected to send no endpoint until the operator + // picks one. The picker therefore has to show that same resolution: an + // executor merely discovered on this machine is not a reason to present + // itself as the steward's runtime, or the header chip, the composer and the + // answer would tell three different stories. + const declaredStewardEndpoint = managerChannelBinding?.executor_endpoint?.trim() ?? ""; + const stewardExecutorAgentId = declaredStewardEndpoint + ? discoveredAgents.find((agent) => agent.agentId === declaredStewardEndpoint)?.agentId + : undefined; + const agentDefaultForContext = (targetContextId: string) => + targetContextId === "manager" ? stewardExecutorAgentId ?? defaultAgentId : defaultAgentId; const [selectedAgents, setSelectedAgents] = useState>(readPersonalAgentSelections); - const selectedAgentId = selectedAgents[contextId] ?? defaultAgentId; + const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId); const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId); const [agentMenuOpen, setAgentMenuOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md index ef5ee95768..d8fbf83aa3 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md @@ -474,6 +474,42 @@ Validation: `tests/capabilities/test_steward_executor_machine_defaults.py`, `tests/capabilities/test_capability_configuration_ui.py`, and `examples/loopx-steward-channel-binding-smoke.py`. +### Steward Answer Identity and Runtime Selection (2026-09-16) + +Once a machine could declare its steward executor, the Dashboard was still +answering two different questions with one value: *who answered me* and *which +runtime served the turn*. The transcript titled a steward answer with whatever +the chat-runtime picker happened to hold, and the picker itself preferred a +`Codex` adapter whenever one was discovered on the machine -- so a channel +resolving to the managed host could still present itself as an individual CLI +login, and the header chip, the composer and the answer could each name a +different executor. + +Two rules now hold on the manager channel: + +* **Answer identity names the speaker.** The transcript labels a steward answer + `LoopX Manager` / `LoopX 管家` on every path that can produce one -- return + receipts, resumed history, recovery streaming, the streaming placeholder, the + completion fallback, interruption and failure. The executor and its model + stay in the machine-capability chip, which is the surface that reports them. + Goal channels keep naming the Goal's own Agent. +* **Runtime selection resolves the way the channel resolves.** The chat-runtime + picker follows the channel owner's precedence for the manager context: the + declared steward executor first, the shipped default only when the machine + declares nothing. A discovered adapter is never presented as the steward. + +An explicit operator pick still wins for that context, and the client still +sends no endpoint when the operator made no pick, so the create-session contract +in `loopx/chat_server.py` -- "each channel resolves its own default through its +own owner" -- is unchanged. An explicit pick is also the only path that moves +this channel off the declared executor, which keeps a discovered CLI from +silently rewriting a machine decision. + +Evidence: `examples/personal-workspace-browser-smoke.mjs` (`execution-chip` +scenario for the picker and composer resolution, `chat-recovery` scenario for +the answer identity), plus a live readback on the installed Dashboard where the +header chip resolved `dsh` while the picker previously reported `Codex`. + ## Steward Team Intake (2026-09-16) The steward answers questions. Since `2026-09-16` its shipped guidance also diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md index 36ebdd25d4..b037111d43 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md @@ -373,6 +373,32 @@ journal 与配额语义;B 作为上游接口出现时的低成本替代;只 `tests/capabilities/test_capability_configuration_ui.py`,以及 `examples/loopx-steward-channel-binding-smoke.py`。 +### 管家回答身份与 Runtime 选择(2026-09-16) + +机器可以声明管家执行器之后,Dashboard 仍在用一个值回答两个不同的问题:**谁在回答我**, +以及**哪个 runtime 跑了这一回合**。会话记录用聊天 Runtime 选择器当时持有的值给管家回答 +署名;而选择器本身只要在本机发现 `Codex` 适配器就优先选它——于是解析到托管宿主的通道 +仍可能把自己呈现成个人 CLI 登录,页头 chip、输入框与回答可以各说各的执行器。 + +管家通道上现在有两条规则: + +* **回答身份署名说话者。** 会话记录在**所有**能产生管家回答的路径上署名 + `LoopX 管家` / `LoopX Manager`:交接回执、恢复的历史、恢复流式、流式占位、 + 完成兜底、中断与失败。执行器与模型留在机器能力 chip 上,那才是回报它们的表面。 + Goal 通道继续署名该 Goal 自己的 Agent。 +* **Runtime 选择按通道的解析方式解析。** 在管家上下文里,聊天 Runtime 选择器遵循通道 + 属主的优先级:先看本机声明的管家执行器,只有本机什么都没声明时才回落到出货默认值。 + 一个被发现的适配器永远不会被呈现成管家。 + +运维方的显式选择仍然在该上下文里优先;运维方没有选择时,客户端依旧不发送任何端点, +因此 `loopx/chat_server.py` 的建会话契约——"每个通道通过自己的属主解析自己的默认值" +——没有变化。显式选择也是这条通道离开已声明执行器的唯一路径,这让一个被发现的 CLI +不会静默改写一项机器决定。 + +证据:`examples/personal-workspace-browser-smoke.mjs`(`execution-chip` 场景覆盖选择器与 +输入框的解析,`chat-recovery` 场景覆盖回答身份),以及在已安装 Dashboard 上的一次现场 +回读:页头 chip 解析为 `dsh`,而选择器此前报的是 `Codex`。 + ## 管家团队入端口径(2026-09-16) 管家负责回答问题。自 2026-09-16 起,它的出厂指引里还带了一段有界流程,用于另一类请求: diff --git a/examples/personal-workspace-browser/execution-chip.mjs b/examples/personal-workspace-browser/execution-chip.mjs index 805ea66c13..72df23e75b 100644 --- a/examples/personal-workspace-browser/execution-chip.mjs +++ b/examples/personal-workspace-browser/execution-chip.mjs @@ -196,6 +196,62 @@ export const executionChipScenario = { coverageEntries.push(...await managed.close()); } + // The chat-runtime picker has to report the same resolution as the chip: a + // managed host declared for this machine is what will answer, so neither + // the header trigger nor the composer may keep advertising the CLI the + // machine merely happens to have installed. + const managedHostAdapters = [{ + adapter_kind: "deepseek_harness_segment", + agent_id: "dsh", + available: true, + display_name: "DeepSeek Harness (managed)", + interrupt: false, + resume: true, + streaming: false, + }]; + const stewardPicker = await openWorkspacePage(browser, url, { + apiOptions: { managerChannelBinding: selectedManagedBinding, runtimeAgents: managedHostAdapters }, + collectCoverage, + }); + try { + const pickerLabel = (await stewardPicker.page + .locator("div.personal-agent-select button.personal-select-trigger") + .innerText()).replace(/\s+/g, " ").trim(); + if (!pickerLabel.includes("DeepSeek Harness (managed)")) { + throw new Error(`Chat runtime picker ignored the declared steward executor: ${pickerLabel}`); + } + if (pickerLabel.includes("Codex")) { + throw new Error(`Chat runtime picker advertised a discovered CLI as the steward: ${pickerLabel}`); + } + const composerLabel = (await stewardPicker.page + .locator(".personal-channel-composer > span") + .first() + .innerText()).trim(); + if (composerLabel !== "DeepSeek Harness (managed)") { + throw new Error(`Composer named ${composerLabel} instead of the steward's declared executor`); + } + } finally { + coverageEntries.push(...await stewardPicker.close()); + } + + // A machine that declares no steward executor keeps the shipped default, + // so the parity above is a resolution the control plane asked for and not + // a new hard-coded preference. + const undeclaredSteward = await openWorkspacePage(browser, url, { + apiOptions: { runtimeAgents: managedHostAdapters }, + collectCoverage, + }); + try { + const pickerLabel = (await undeclaredSteward.page + .locator("div.personal-agent-select button.personal-select-trigger") + .innerText()).replace(/\s+/g, " ").trim(); + if (pickerLabel !== "Chat Codex") { + throw new Error(`An undeclared steward executor no longer used the shipped default: ${pickerLabel}`); + } + } finally { + coverageEntries.push(...await undeclaredSteward.close()); + } + // A managed host that cannot launch here names the missing fact, and never // claims the channel lacks a transport it now has. for (const [binding, expected] of [ @@ -306,7 +362,7 @@ export const executionChipScenario = { } return { coverageEntries, - note: "execution chip reports the selected executor, the credential it is billed to and the resolved model, names the steward channel's shipped default without claiming a credential branch, keeps a configured credential from moving it, names why a selected managed host cannot launch, and stays absent without a binding", + note: "execution chip reports the selected executor, the credential it is billed to and the resolved model, names the steward channel's shipped default without claiming a credential branch, keeps a configured credential from moving it, keeps the chat-runtime picker on the executor the machine declares for the steward, names why a selected managed host cannot launch, and stays absent without a binding", }; }, }; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index cb4ac87f1b..543623a8fc 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -288,7 +288,7 @@ function filterStatusFixtureToScope(fixture, statusGeneration, scope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, runtimeAgents = null } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -1251,7 +1251,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true { agent_id: "codex", display_name: "Codex", adapter_kind: "codex_app_server", available: true, streaming: true, resume: true, interrupt: true }, { agent_id: "claude-code", display_name: "Claude Code", adapter_kind: "claude_code_cli", available: true, streaming: true, resume: true, interrupt: true }, { agent_id: "offline-agent", display_name: "Offline Agent", adapter_kind: "acp", available: false, streaming: false, resume: false, interrupt: false }, - ], + ].concat(runtimeAgents ?? []), }, status: 200 }); return; } diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 82118d902b..16999f6a86 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1611,7 +1611,7 @@ export const typedActionsScenario = { await page.keyboard.press("Escape"); if (await agentListbox.isVisible().catch(() => false)) throw new Error("Agent menu did not close on Escape"); if (!(await agentSelect.evaluate((element) => element === document.activeElement))) throw new Error("Agent menu did not restore trigger focus"); - pass(14, "Codex remained the healthy default and the unavailable Agent option was disabled with explanation."); + pass(14, "With no declared steward executor the shipped Codex default held, and the unavailable Agent option was disabled with explanation."); await agentSelect.click(); const reopenedAgentListbox = page.getByRole("listbox", { name: "选择聊天 Runtime" }); await reopenedAgentListbox.getByRole("option", { name: "Claude Code", exact: true }).click();