From 7a6cd28b0589ac64b4b2b9309b729ddfde59f84e Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 10:47:31 +0800 Subject: [PATCH 1/5] fix(chat): tell a fresh install which Agent its first Goal is missing On a real fresh install without a usable Agent, "Create a Goal" answered with the service's protocol sentence ("selected Agent endpoint is not healthy", error_code invalid_action_preview). The owner who was promised a first use without a terminal got no name, no reason and no next step. The creation preview now refuses an unavailable first Agent as a typed gate (kind agent_unavailable) carrying the Agent id, whether it is registered, and the catalog's own reason fields when it states them (unavailable_reason, executable, environment_variable). Both the normalization-time check and the preview's project-bound check go through it; apply and every other caller keep the existing eligibility errors. Nothing is written and nothing is granted; a usable Agent goes through the same check as before. The page maps that gate to owner-language copy in both locales: which Agent cannot start the Goal, why (the missing command or variable when known, otherwise not installed or not signed in), that nothing was written and the form keeps its draft, and what to do next. The drawer shows the gate's own title and reason instead of the host-confirmation copy. Validation: an HTTP-level test (unavailable, unregistered, then available Agent), the contract test, and a fixture scenario in which the service answers the preview with the same gate shape and the owner reads the reason without any protocol sentence. The fixture gained a hook to answer a preview with a typed gate. The user guide states the behavior. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../personal-workspace/context-drawer.tsx | 2 +- .../src/features/personal-workspace/i18n.tsx | 16 ++++ .../personal-workspace-contract.test.mjs | 11 +++ .../personal-workspace-page.tsx | 29 +++++-- docs/guides/personal-workspace-user-guide.md | 6 +- examples/personal-workspace-browser-smoke.mjs | 2 + .../first-goal-agent-unavailable.mjs | 73 +++++++++++++++++ .../personal-workspace-browser/fixture.mjs | 14 ++++ loopx/chat_action_normalization.py | 4 +- loopx/chat_actions.py | 47 ++++++++++- tests/test_chat_goal_workspace_selection.py | 80 +++++++++++++++++++ 11 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 examples/personal-workspace-browser/first-goal-agent-unavailable.mjs diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 506cc30ba2..89760923dd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1188,7 +1188,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ?

{t("proposal.primary.operationCancelled")}

: null} {selection.item.status === "deferred" ?

{t("drawer.proposalDeferred")}

: null} {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ?
{t("drawer.gateDecisionRecheckTitle")}{t("drawer.gateDecisionRecheck")}
- : selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} + : selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} {!readOnly && selection.item.workspaceCandidates?.length ?
{selection.item.workspaceCandidates.map((candidate) => )}
: null} {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (!["team.plan", "goal.create"].includes(selection.item.actionKind) || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(["team.plan", "goal.create"].includes(selection.item.actionKind) && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && !selection.item.reviewPlan?.creationProgress?.goalId && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index c47489e0f9..9b2ac08e25 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1014,6 +1014,14 @@ const en = { "proposal.workspaceGate.defaultSummary": "Select the Goal workspace.", "proposal.workspaceGate.selectionImpact": "After selecting a workspace, LoopX will show the confirmation preview again. The selection itself does not write state.", "proposal.workspaceGate.selectionTitle": "Select Goal workspace", + "proposal.agentGate.title": "{agent} is not ready to start this Goal", + "proposal.agentGate.summary": "The Agent that would run this Goal is not available right now: {reason}.", + "proposal.agentGate.reason.executable_not_found": "the command {executable} was not found on this machine", + "proposal.agentGate.reason.api_key_not_set": "the environment variable {variable} is not set", + "proposal.agentGate.reason.unregistered": "it is not registered in this LoopX", + "proposal.agentGate.reason.unavailable": "it is not installed or not signed in", + "proposal.agentGate.nextAction": "Install and sign in to {agent}, then create the Goal again. The home page's runtime notice shows the current check.", + "proposal.agentGate.impact": "No Goal has been written. The same form and choices stay available.", "projection.agentWorkQueued": "Agent work is queued for this Goal", "projection.agentIdle": "Nothing needs your attention", "projection.agentNeedsDecision": "Agent is waiting for your decision", @@ -2537,6 +2545,14 @@ const zhCN: Record = { "proposal.workspaceGate.defaultSummary": "请选择 Goal 所属工作区。", "proposal.workspaceGate.selectionImpact": "选择工作区后会重新展示待确认操作,选择本身不会写入状态。", "proposal.workspaceGate.selectionTitle": "选择 Goal 工作区", + "proposal.agentGate.title": "{agent} 还不能开始这个 Goal", + "proposal.agentGate.summary": "负责推进这个 Goal 的 Agent 当前不可用:{reason}。", + "proposal.agentGate.reason.executable_not_found": "本机没有找到命令 {executable}", + "proposal.agentGate.reason.api_key_not_set": "环境变量 {variable} 未设置", + "proposal.agentGate.reason.unregistered": "它没有在这个 LoopX 中登记", + "proposal.agentGate.reason.unavailable": "它尚未安装或尚未登录", + "proposal.agentGate.nextAction": "先安装并登录 {agent},再重新创建 Goal。首页的运行时提示会显示当前检查结果。", + "proposal.agentGate.impact": "没有写入任何 Goal,表单与已填内容仍可继续使用。", "projection.agentWorkQueued": "这个 Goal 有待 Agent 推进的工作", "projection.agentIdle": "暂无需要你处理", "projection.agentNeedsDecision": "Agent 等待你的决定", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 05c8e70057..0b3bdc8ffd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -659,3 +659,14 @@ for (const route of ["./views/frontstage-developer-page", "./views/benchmark-stu assert.equal((page.match(//g) ?? []).length, 1, "The lazy settings site shows a loading status"); console.log("personal workspace drawer contract smoke passed"); + +// An unavailable first Agent: the service refuses the creation preview with a +// typed gate (kind, agent, catalog reason) and the page tells the owner in +// their language, never with the protocol sentence; nothing is written. +assert.match(page, /const agentGate = gateKind === "agent_unavailable"/, "The page maps the typed Agent gate to owner-language copy"); +assert.match(page, /reasonCode === "executable_not_found"[\s\S]*?reasonCode === "api_key_not_set"[\s\S]*?rawGate\.registered === false/, "The reason follows the catalog's own fields"); +assert.match(source("./context-drawer.tsx"), /selection\.item\.gate\?\.kind === "agent_unavailable" \? selection\.item\.title/, "The drawer shows the Agent gate's own title, not host confirmation"); +for (const key of ["proposal.agentGate.title", "proposal.agentGate.summary", "proposal.agentGate.reason.executable_not_found", "proposal.agentGate.reason.api_key_not_set", "proposal.agentGate.reason.unregistered", "proposal.agentGate.reason.unavailable", "proposal.agentGate.nextAction", "proposal.agentGate.impact"]) { + assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); +} + 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 37c24c4991..b1ce0ac4ef 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 @@ -36,8 +36,7 @@ import { type LarkGoalConnection, type ManagerChannelBinding, type ManagerRuntimeSessionReadback, - type TypedActionProposal, -} from "../../data/chat"; + type TypedActionProposal, agentBackendLabel } from "../../data/chat"; import { useTypedActionReadback, useTypedActionProposalReadback } from "../../data/use-typed-action-readback"; import { ChannelHeader } from "./channel-header"; @@ -1546,6 +1545,24 @@ export function PersonalWorkspacePage({ const gateKind = String(rawGate.kind ?? "workspace_selection_required"); const requiresAgentBinding = gateKind === "agent_binding_required" || gateKind === "agent_identity_selection_required"; + // An unavailable first Agent is told in the owner's language from the + // service's typed facts (which Agent, the catalog's own reason), never + // from its protocol sentence; the form and its draft stay available. + const agentGate = gateKind === "agent_unavailable" ? (() => { + const agent = agentBackendLabel(String(rawGate.agent_id ?? "codex")); + const reasonCode = String(rawGate.unavailable_reason ?? ""); + const reason = reasonCode === "executable_not_found" && typeof rawGate.executable === "string" + ? t("proposal.agentGate.reason.executable_not_found", { executable: rawGate.executable }) + : reasonCode === "api_key_not_set" && typeof rawGate.environment_variable === "string" + ? t("proposal.agentGate.reason.api_key_not_set", { variable: rawGate.environment_variable }) + : rawGate.registered === false ? t("proposal.agentGate.reason.unregistered") : t("proposal.agentGate.reason.unavailable"); + return { + title: t("proposal.agentGate.title", { agent }), + summary: t("proposal.agentGate.summary", { reason }), + nextAction: t("proposal.agentGate.nextAction", { agent }), + impact: t("proposal.agentGate.impact"), + }; + })() : null; local = { actionKind: request.actionKind, fields: workspaceCandidates.map((candidate) => ({ @@ -1555,16 +1572,16 @@ export function PersonalWorkspacePage({ })), gate: { kind: gateKind, - nextAction: typeof rawGate.next_action === "string" ? rawGate.next_action : undefined, - summary: String(rawGate.summary ?? t("proposal.workspaceGate.defaultSummary")), + nextAction: agentGate ? agentGate.nextAction : typeof rawGate.next_action === "string" ? rawGate.next_action : undefined, + summary: agentGate ? agentGate.summary : String(rawGate.summary ?? t("proposal.workspaceGate.defaultSummary")), }, - impact: requiresAgentBinding + impact: agentGate ? `${agentGate.summary} ${agentGate.impact}` : requiresAgentBinding ? t("proposal.workspaceGate.agentImpact") : t("proposal.workspaceGate.selectionImpact"), previewId: `workspace-choice-${Date.now().toString(36)}`, sourceRequest: request, status: "gated", - title: requiresAgentBinding ? t("proposal.workspaceGate.agentTitle") : t("proposal.workspaceGate.selectionTitle"), + title: agentGate ? agentGate.title : requiresAgentBinding ? t("proposal.workspaceGate.agentTitle") : t("proposal.workspaceGate.selectionTitle"), workspaceCandidates, }; } diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index d40dd1f3be..e44625bf7c 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -77,14 +77,16 @@ graph TD Goal 是什么、一个 **创建 Goal** 按钮(打开与侧边栏 `+` 相同的创建表单),以及三条示例 请求。点选示例只会填入输入框,按发送前不会发出任何内容。状态来源自身的检查行(例如 本机尚未建立注册表)折叠在「状态来源详情」里。只有状态来源真的不健康(例如注册表损坏) -时才显示健康告警,并以可读句子描述问题,原始检查行同样折叠在「诊断信息」里。 +时才显示健康告警,并以可读句子描述问题,原始检查行同样折叠在「诊断信息」里。本机还没有可用的 +Agent(例如 Codex 未安装或未登录)时,「创建 Goal」的检查会直接告诉你是哪个 Agent、缺什么(命令 +或环境变量)以及下一步怎么做,不会写入任何内容,表单内容也会保留。 When no Goal exists yet, the overview shows a first-Goal state instead of empty lanes or a health banner: what a Goal is, a **Create a Goal** button (the same form as the sidebar `+`), and three example requests that only fill the composer. The status source's own check lines (such as "no registry yet on this machine") stay collapsed under "Status source details". A health banner appears only when the source is actually unhealthy, in plain -sentences, with the raw check lines collapsed under "Diagnostics". +sentences, with the raw check lines collapsed under "Diagnostics". When this machine has no usable Agent yet (Codex not installed or not signed in), the Goal creation review names that Agent, what is missing (the command or the environment variable) and the next step; nothing is written and the form keeps what you typed. ### 核心功能区 1. **「你不在的时候」离线概览**: diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 80d6a4f4f9..3339f04a5e 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -8,6 +8,7 @@ import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal- import {sinceYouLookedScenario} from "./personal-workspace-browser/since-you-looked.mjs"; import {sinceYouLookedLiveScenario} from "./personal-workspace-browser/since-you-looked-live.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {firstGoalAgentUnavailableScenario} from "./personal-workspace-browser/first-goal-agent-unavailable.mjs"; import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; @@ -117,6 +118,7 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(firstGoalAgentUnavailableScenario); scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; diff --git a/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs b/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs new file mode 100644 index 0000000000..f0eb69ccd5 --- /dev/null +++ b/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// A fresh install without a usable Agent: the service refuses the first Goal's +// preview with a typed gate (which Agent, the catalog's reason). The page must +// tell the owner in their own language what is missing and what to do, never +// show the protocol sentence, write nothing, keep the form's draft, and go +// through once the Agent is available. The fixture answers the preview with +// the same gate shape the real service sends. + +function emptyWorkspace(fixture) { + fixture.goal_count = 0; + fixture.run_count = 0; + fixture.run_history.goals = []; + fixture.run_history.recent_runs = []; + fixture.run_history.goal_count = 0; + fixture.run_history.run_count = 0; + fixture.attention_queue.items = []; + fixture.attention_queue.item_count = 0; + fixture.attention_queue.needs_user_or_controller = 0; + fixture.attention_queue.needs_codex = 0; + fixture.attention_queue.watching_external_evidence = 0; + if (fixture.usage_summary) fixture.usage_summary.goals = []; + fixture.contract = { ok: true, summary: { errors: 0, warnings: 1, checks: 1 }, errors: [], + warnings: ["registry file does not exist: [local-path]"], checks: ["run-history goals=0 runs=0"] }; +} + +export const firstGoalAgentUnavailableScenario = { + id: "first-goal-agent-unavailable", + async run({ browser, collectCoverage, url }) { + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (api) => { api.transformStatus = (fixture) => emptyWorkspace(fixture); } }); + const { api, page } = session; + try { + const firstGoal = page.getByTestId("personal-first-goal"); + await firstGoal.waitFor({ state: "visible", timeout: 15_000 }); + await firstGoal.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); + await form.waitFor({ state: "visible", timeout: 10_000 }); + await form.getByLabel("目标").fill("Track the weekly release checklist"); + await form.getByLabel("完成标准").fill("A checklist exists and is reviewed every week"); + api.nextActionPreviewGate = { kind: "agent_unavailable", agent_id: "codex", registered: true, unavailable_reason: "executable_not_found", executable: "/opt/loopx/bin/codex", + summary: "The selected Agent (codex) is not available to start this Goal.", next_action: "Install and sign in to the Agent, then create the Goal again.", detail: "selected Agent endpoint is not healthy" }; + await form.locator("button[type=submit]").click(); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 10_000 }); + const text = (await drawer.innerText()).replace(/\s+/g, " "); + assert.match(text, /还不能开始这个 Goal/, `the owner reads which Agent cannot start the Goal: ${text.slice(0, 300)}`); + assert.match(text, /本机没有找到命令 \/opt\/loopx\/bin\/codex/, "the catalog's own reason is shown"); + assert.match(text, /先安装并登录/, "the next action is stated"); + assert.match(text, /没有写入任何 Goal/, "nothing was written"); + assert.doesNotMatch(text, /endpoint is not healthy|not available to start/, "no protocol sentence reaches the owner"); + await page.screenshot({ path: resolve(outputDir, "first-goal-agent-unavailable.png"), animations: "disabled" }); + assert.equal(api.actionPreviews.filter((row) => row.gated).length, 1, "exactly one refused preview"); + + // Once the Agent is available the same request previews normally. + await page.locator(".personal-drawer-close").click(); + await firstGoal.locator(".personal-first-goal-create").click(); + await form.waitFor({ state: "visible", timeout: 10_000 }); + await form.getByLabel("目标").fill("Track the weekly release checklist"); + await form.getByLabel("完成标准").fill("A checklist exists and is reviewed every week"); + await form.locator("button[type=submit]").click(); + await drawer.waitFor({ state: "visible", timeout: 10_000 }); + await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); + return { coverageEntries: await session.close() }; + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 117aa6a8b8..d82f6b0307 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -445,6 +445,9 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu failNextGoalSubagentResponse: false, failNextLifecyclePreview: false, failNextActionPreview: false, + // When set, the next action preview answers with this typed gate (409), as + // the real service does for a refusal that writes nothing. + nextActionPreviewGate: null, failNextStatusRequest: false, /** When set, every status read answers this HTTP status until cleared: a source that cannot be projected. */ statusFailure: null, @@ -1889,6 +1892,17 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu }); return; } + if (state.nextActionPreviewGate) { + const gate = state.nextActionPreviewGate; + state.nextActionPreviewGate = null; + state.actionPreviews.push({ ...body, gated: gate.kind }); + await route.fulfill({ + contentType: "application/json", + json: { ok: false, schema_version: "loopx_chat_action_preview_gate_v1", error: "This preview needs an explicit public-safe selection.", error_code: "action_preview_gate", gate, write_attempted: false }, + status: 409, + }); + return; + } if (body.action_kind === "goal.lifecycle" && state.failNextLifecyclePreview) { state.failNextLifecyclePreview = false; await route.fulfill({ diff --git a/loopx/chat_action_normalization.py b/loopx/chat_action_normalization.py index 9a99646087..043afb8065 100644 --- a/loopx/chat_action_normalization.py +++ b/loopx/chat_action_normalization.py @@ -566,7 +566,9 @@ def _normalize( if values.get(field): result[field] = _opaque(values[field], field=field) if result.get("agent_id"): - self._agent_eligibility(str(result["agent_id"])) + # A creation's Agent is refused as a typed gate, so a fresh install + # learns which Agent is missing instead of reading a protocol sentence. + self._creation_agent_eligibility(str(result["agent_id"])) if values.get("heartbeat") is not None: if not isinstance(values["heartbeat"], Mapping): raise ValueError("heartbeat must be an object") diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 04a1df8f42..e174c364ba 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -301,6 +301,51 @@ def _registry_fingerprint(self) -> str: raise ValueError("the active LoopX registry is unavailable") from exc return hashlib.sha256(content).hexdigest() + def _creation_agent_eligibility( + self, agent_id: str, *, project: Path | None = None + ) -> dict[str, Any]: + """Eligibility for a Goal's first Agent, refused as a typed gate. + + A fresh install often has no usable Agent yet. The owner who clicks + "Create a Goal" needs the reason in their own language and the way to + fix it, not a protocol sentence: the preview answers with a gate that + names the Agent and, when the catalog states it, why it is unavailable. + Nothing is written and nothing is granted; a working Agent still goes + through the same eligibility check. + """ + + try: + return self._agent_eligibility(agent_id, project=project) + except ValueError as exc: + capabilities = ( + self.runtime_controller.capabilities() + if self.runtime_controller is not None + else [] + ) + row = next( + ( + item + for item in capabilities + if isinstance(item, Mapping) + and str(item.get("agent_id") or "") == agent_id + ), + None, + ) + gate: dict[str, Any] = { + "kind": "agent_unavailable", + "agent_id": agent_id, + "registered": row is not None, + "summary": f"The selected Agent ({agent_id}) is not available to start this Goal.", + "next_action": "Install and sign in to the Agent, then create the Goal again.", + "detail": str(exc), + } + if row is not None: + for field in ("unavailable_reason", "executable", "environment_variable"): + value = row.get(field) + if isinstance(value, str) and value: + gate[field] = value + raise ProtectedActionGate("goal.create", gate=gate) from exc + def _agent_eligibility( self, agent_id: str, @@ -1190,7 +1235,7 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: } ) agent_id = str(normalized.get("agent_id") or "codex") - eligibility = self._agent_eligibility(agent_id, project=project) + eligibility = self._creation_agent_eligibility(agent_id, project=project) workspace_binding = self._goal_workspace_binding( project, source_goal, requested_ref=str(normalized.get("workspace_ref") or "current") ) diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index b7b8fec54d..3682bce047 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -240,3 +240,83 @@ def request(path, body=None): server.shutdown() server.server_close() thread.join(timeout=5) + + +def test_first_goal_preview_refuses_an_unavailable_agent_as_a_typed_gate(tmp_path): + # A fresh install often has no usable Agent yet. "Create a Goal" must not + # fail with a protocol sentence: the preview answers with a typed gate that + # names the Agent and the catalog's own reason, writes nothing, and the same + # request previews once the Agent is available. + import http.client + import runpy + import threading + from pathlib import Path + + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from loopx.chat_actions import goal_workspace_ref + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.chat_store import ChatSessionStore + + fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples/loopx-chat-actions-smoke.py")) + runtime = tmp_path / "runtime" + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + sessions = ChatSessionStore(runtime) + controller = fixture["FakeRuntimeController"](sessions) + controller.project_contexts = ChatProjectContexts([project]) + catalog = {"rows": [{"agent_id": "codex", "available": False, "tool_calls": True, "trust_scope": "read_only", + "unavailable_reason": "executable_not_found", "executable": "/opt/bins/codex"}]} + controller.capabilities = lambda: list(catalog["rows"]) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.runtime_root_override = registry, runtime, str(runtime) + server.action_store = ChatActionStore(runtime / "chat/actions") + server.action_service = ChatActionService(store=server.action_store, registry_path=registry, + chat_store=sessions, runtime_controller=controller, workspace_roots=[project]) + server.chat_store, server.runtime_controller, server.verbose = sessions, controller, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(path, body=None): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + try: + connection.request("GET" if body is None else "POST", path, + None if body is None else json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + create = {"action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": "no-agent", + "context": {"kind": "manager", "goal_id": None}, + "normalized_parameters": {"goal_id": "goal-first", "title": "First Goal", "objective": "Start here", + "agent_id": "codex", "workspace_ref": goal_workspace_ref(project)}} + try: + status, gated = request("/api/actions/preview", create) + assert status == 409, gated + assert gated["error_code"] == "action_preview_gate" and gated["write_attempted"] is False + gate = gated["gate"] + assert gate["kind"] == "agent_unavailable" and gate["agent_id"] == "codex" and gate["registered"] is True + assert gate["unavailable_reason"] == "executable_not_found" and gate["executable"] == "/opt/bins/codex" + assert not registry.exists(), "a refused preview writes nothing" + assert request("/api/actions")[1]["proposals"] == [], "a refused preview stores no proposal" + + catalog["rows"] = [] + status, unregistered = request("/api/actions/preview", create) + assert status == 409 and unregistered["gate"]["kind"] == "agent_unavailable" + assert unregistered["gate"]["registered"] is False and "unavailable_reason" not in unregistered["gate"] + + # With a usable Agent the same request previews. (A registry is written + # here only because this check is about the Agent gate, not about the + # fresh-install fingerprint that a separate change covers.) + catalog["rows"] = [{"agent_id": "codex", "available": True, "tool_calls": True, "trust_scope": "read_only"}] + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text(json.dumps({"schema_version": 1, "goals": []}), encoding="utf-8") + status, previewed = request("/api/actions/preview", create) + assert status == 201, previewed + assert previewed["proposal"]["action_kind"] == "goal.create" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + From f755d8c15d54c617786c59f5ea03c2bd88664f50 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 21:49:12 +0800 Subject: [PATCH 2/5] fix(chat): preserve the actual first-Agent eligibility refusal Signed-off-by: song --- .../src/features/personal-workspace/i18n.tsx | 8 ++-- .../workspace-action-form.tsx | 5 ++- loopx/chat_actions.py | 37 +++++++++---------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index beffbf8117..84d0df7345 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1036,8 +1036,8 @@ const en = { "proposal.agentGate.reason.executable_not_found": "the command {executable} was not found on this machine", "proposal.agentGate.reason.api_key_not_set": "the environment variable {variable} is not set", "proposal.agentGate.reason.unregistered": "it is not registered in this LoopX", - "proposal.agentGate.reason.unavailable": "it is not installed or not signed in", - "proposal.agentGate.nextAction": "Install and sign in to {agent}, then create the Goal again. The home page's runtime notice shows the current check.", + "proposal.agentGate.reason.unavailable": "the catalog marks it unavailable without a more specific reason", + "proposal.agentGate.nextAction": "Resolve the reported availability issue for {agent}, then recheck this request. The home page's runtime notice shows the current check.", "proposal.agentGate.impact": "No Goal has been written. The same form and choices stay available.", "projection.agentWorkQueued": "Agent work is queued for this Goal", "projection.agentIdle": "Nothing needs your attention", @@ -2592,8 +2592,8 @@ const zhCN: Record = { "proposal.agentGate.reason.executable_not_found": "本机没有找到命令 {executable}", "proposal.agentGate.reason.api_key_not_set": "环境变量 {variable} 未设置", "proposal.agentGate.reason.unregistered": "它没有在这个 LoopX 中登记", - "proposal.agentGate.reason.unavailable": "它尚未安装或尚未登录", - "proposal.agentGate.nextAction": "先安装并登录 {agent},再重新创建 Goal。首页的运行时提示会显示当前检查结果。", + "proposal.agentGate.reason.unavailable": "目录将它标为不可用,未提供更具体原因", + "proposal.agentGate.nextAction": "根据上述原因修复 {agent} 的可用性,再按最新状态重新检查。首页的运行时提示会显示当前检查结果。", "proposal.agentGate.impact": "没有写入任何 Goal,表单与已填内容仍可继续使用。", "projection.agentWorkQueued": "这个 Goal 有待 Agent 推进的工作", "projection.agentIdle": "暂无需要你处理", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx index 01e945be26..e1ce1d2701 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx @@ -42,6 +42,7 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], workspaceProje const dialog = useRef(null); const headingId = useId(); const submitting = useRef(false); + const goalRequest = useRef<{ content: string; operationId: string } | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [objective, setObjective] = useState(draft.text ?? ""); @@ -78,8 +79,10 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], workspaceProje setError(""); try { if (draft.kind === "goal") { + const content = JSON.stringify([objective, completion, boundary, permission, workspaceRef, draft.agentId, draft.goalId, locale]); + if (goalRequest.current?.content !== content) goalRequest.current = { content, operationId: crypto.randomUUID() }; await onPreview(goalCreateRequest({ objective, completion, boundary, permission, workspaceRef, - agentId: draft.agentId, contextGoalId: draft.goalId }, t)); + agentId: draft.agentId, contextGoalId: draft.goalId, operationId: goalRequest.current.operationId }, t)); onClose(); return; } diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index be90041f9d..b5bba582c5 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -95,6 +95,18 @@ def __init__( super().__init__(self.gate["summary"]) +class _AgentUnavailableError(ValueError): + """Availability refusal from the existing endpoint eligibility owner. + + Tool, trust and workspace admission failures remain ordinary ValueErrors; + callers must not infer this narrower condition from their message text. + """ + + def __init__(self, message: str, *, capability: Mapping[str, Any] | None = None) -> None: + self.capability = dict(capability) if capability is not None else None + super().__init__(message) + + def _opaque(value: Any, *, field: str) -> str: token = str(value or "").strip() if not _OPAQUE_ID.fullmatch(token): @@ -322,7 +334,7 @@ def _registry_fingerprint(self) -> str: def _creation_agent_eligibility( self, agent_id: str, *, project: Path | None = None ) -> dict[str, Any]: - """Eligibility for a Goal's first Agent, refused as a typed gate. + """Availability refusal for a Goal's first Agent becomes a typed gate. A fresh install often has no usable Agent yet. The owner who clicks "Create a Goal" needs the reason in their own language and the way to @@ -334,27 +346,14 @@ def _creation_agent_eligibility( try: return self._agent_eligibility(agent_id, project=project) - except ValueError as exc: - capabilities = ( - self.runtime_controller.capabilities() - if self.runtime_controller is not None - else [] - ) - row = next( - ( - item - for item in capabilities - if isinstance(item, Mapping) - and str(item.get("agent_id") or "") == agent_id - ), - None, - ) + except _AgentUnavailableError as exc: + row = exc.capability gate: dict[str, Any] = { "kind": "agent_unavailable", "agent_id": agent_id, "registered": row is not None, "summary": f"The selected Agent ({agent_id}) is not available to start this Goal.", - "next_action": "Install and sign in to the Agent, then create the Goal again.", + "next_action": "Resolve the reported availability issue, then preview this request again.", "detail": str(exc), } if row is not None: @@ -390,9 +389,9 @@ def _agent_eligibility( None, ) if row is None: - raise ValueError("selected Agent endpoint is not registered") + raise _AgentUnavailableError("selected Agent endpoint is not registered") if row.get("available") is not True: - raise ValueError("selected Agent endpoint is not healthy") + raise _AgentUnavailableError("selected Agent endpoint is not healthy", capability=row) if requires_tools and row.get("tool_calls") is not True: raise ValueError("selected Agent endpoint cannot execute Goal tools") if str(row.get("trust_scope") or "") not in {"read_only", "workspace_write"}: From 0c57ad86bd142b82c264dbc38904341a5a3d6c8a Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 21:49:13 +0800 Subject: [PATCH 3/5] test(chat): qualify remote mapping refusal and request recovery Signed-off-by: song --- .../personal-workspace-contract.test.mjs | 1 - .../first-goal-agent-unavailable.mjs | 106 +++++++++++++++++- tests/test_chat_goal_workspace_selection.py | 80 +++++++++++++ 3 files changed, 182 insertions(+), 5 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 516555e3cb..f0cd74cedc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -710,4 +710,3 @@ assert.match(source("./context-drawer.tsx"), /selection\.item\.gate\?\.kind === for (const key of ["proposal.agentGate.title", "proposal.agentGate.summary", "proposal.agentGate.reason.executable_not_found", "proposal.agentGate.reason.api_key_not_set", "proposal.agentGate.reason.unregistered", "proposal.agentGate.reason.unavailable", "proposal.agentGate.nextAction", "proposal.agentGate.impact"]) { assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } - diff --git a/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs b/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs index 9dfa6c010a..8c301d46f6 100644 --- a/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs +++ b/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; import { once } from "node:events"; import { chmod, mkdtemp, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -79,7 +79,7 @@ async function realUnavailableAgent({ browser, collectCoverage, url }) { const text = (await drawer.innerText()).replace(/\s+/g, " "); assert.match(text, /还不能开始这个 Goal/); assert.match(text, /codex/i); - assert.match(text, /先安装并登录/); + assert.match(text, /根据上述原因修复/); assert.match(text, /没有写入任何 Goal/); assert.doesNotMatch(text, /endpoint is not healthy|invalid_action_preview/); assert.doesNotMatch(text, /提案状态刷新失败/, "A refused local preview has no stored action to read back"); @@ -113,6 +113,101 @@ async function realUnavailableAgent({ browser, collectCoverage, url }) { } } + +async function realRemoteMapping({ browser, collectCoverage, url, locale }) { + const root = await mkdtemp(join(tmpdir(), "loopx-first-goal-mapping-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/fresh-install-backend.py"), root, "--no-agent"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"), lines = createInterface({ input: child.stdout }); + let diagnostics = "", session; + child.stderr.on("data", chunk => diagnostics = (diagnostics + chunk).slice(-4000)); + try { + const ready = await Promise.race([once(lines, "line").then(([line]) => JSON.parse(line)), closed.then(() => { throw new Error(`mapping backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + await waitForHttp(`${backend}/status.json?view=workspace-directory`); + const binary = join(ready.bins, "remote-fixture"); + const marker = join(root, "remote-was-executed"); + await writeFile(binary, `#!/bin/sh\necho unexpected > "${marker}"\nexit 0\n`); await chmod(binary, 0o755); + const register = mapped => execFileSync(python, ["-c", ` +import json, sys +from pathlib import Path +from loopx.chat_endpoints import AgentEndpointRegistry +runtime, binary, project, mapped = sys.argv[1:] +endpoint = {"agent_id":"remote-first", "display_name":"Remote fixture", "command":[binary], "location":"remote"} +if mapped == "yes": endpoint["workspace_mapping"] = [{"local_root":project,"agent_root":"/synthetic/workspace"}] +AgentEndpointRegistry(Path(runtime)/"chat").upsert(endpoint) +`, ready.runtime, binary, ready.project, mapped ? "yes" : "no"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } }); + register(false); + const catalog = await (await fetch(`${backend}/api/chat/capabilities`)).json(); + const agent = catalog.adapters.find(row => row.agent_id === "remote-first"); + assert.equal(agent.available, true); assert.equal(agent.tool_calls, true); assert.equal(agent.trust_scope, "read_only"); + const posts = [], previews = [], responses = []; + { + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: locale === "en" ? 390 : 1440, height: 900 }, beforeGoto: async (_api, page) => { + await page.addInitScript(value => localStorage.setItem("loopx-pw-locale", value), locale); + const proxied = new Set(["/status.json", "/api/chat/capabilities", "/api/chat/projects"]); + await page.route(target => proxied.has(target.pathname) || target.pathname.startsWith("/api/actions"), async route => route.fulfill({ response: await fetchWorkspaceBackend(route, backend) })); + } }); + const { page } = session; + await page.emulateMedia({ reducedMotion: "reduce" }); + page.on("request", req => { if (req.method() === "POST") posts.push(new URL(req.url()).pathname); }); + page.on("response", response => { if (new URL(response.url()).pathname === "/api/actions/preview") responses.push(response.status()); }); + page.on("request", req => { if (req.method() === "POST" && new URL(req.url()).pathname === "/api/actions/preview") previews.push(req.postDataJSON()); }); + const first = page.getByTestId("personal-first-goal"); await first.waitFor(); + await first.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); await form.waitFor(); + const objective = form.getByLabel(locale === "en" ? "Objective" : "目标", { exact: true }); + const completion = form.getByLabel(locale === "en" ? "Completion criteria" : "完成标准", { exact: true }); + await objective.fill("Review the remote release checklist"); await completion.fill("A reviewed checklist is retained"); + const before = previews.length; + await form.locator("button[type=submit]").click(); + const notice = page.getByRole("alert").filter({ hasText: "remote Agent endpoint needs a workspace mapping" }); + await notice.waitFor({ timeout: 10_000 }); + assert.equal(previews.at(-1).normalized_parameters.agent_id, "remote-first"); + assert.equal(responses.at(-1), 400); + assert.equal(await form.isVisible(), true); + assert.equal(await objective.inputValue(), "Review the remote release checklist"); + assert.equal(await completion.inputValue(), "A reviewed checklist is retained"); + assert.doesNotMatch(await page.locator("body").innerText(), /先安装并登录|not installed or not signed in|Install and sign in/); + await page.screenshot({ path: resolve(outputDir, `first-goal-mapping-${locale}.png`), animations: "disabled" }); + const registry = join(ready.project, ".loopx", "registry.json"); + await assert.rejects(stat(registry), { code: "ENOENT" }); + assert.deepEqual((await (await fetch(`${backend}/api/actions`)).json()).proposals, []); + let refused = previews.length - 1; + if (locale === "zh-CN") { + // A deliberate edit is a new request; unchanged retry must retain it. + await objective.fill("Review the corrected remote release checklist"); + const rejectedAgain = page.waitForResponse(response => new URL(response.url()).pathname === "/api/actions/preview"); + await form.locator("button[type=submit]").click(); + assert.equal((await rejectedAgain).status(), 400); + refused = previews.length - 1; + assert.notEqual(previews[refused].idempotency_key, previews[before].idempotency_key); + assert.notEqual(previews[refused].normalized_parameters.goal_id, previews[before].normalized_parameters.goal_id); + } + register(true); + await form.locator("button[type=submit]").click(); + const drawer = page.locator(".personal-context-drawer"); await drawer.waitFor(); + await drawer.getByText(locale === "en" ? "New Goal · Awaiting you" : "新 Goal · 待你确认", { exact: true }).waitFor(); + assert.equal(responses.at(-1), 201); + assert.equal(previews.length, refused + 2); + assert.deepEqual(previews[refused + 1], previews[refused], "Only repairing mapping replays the exact request, without retyping"); + await assert.rejects(stat(registry), { code: "ENOENT" }); + await assert.rejects(stat(marker), { code: "ENOENT" }); + assert.ok(posts.every(path => !/\/apply$|\/turns$|\/sessions$/.test(path)), "Mapping repair grants no Goal apply, Session or model Turn"); + await page.keyboard.press("Escape"); + await session.close(); session = null; + } + return []; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.page.screenshot({ path: resolve(outputDir, "first-goal-mapping-failed.png") }).catch(() => {}); + throw error; + } finally { + await session?.close().catch(() => {}); lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } +} + export const firstGoalAgentUnavailableScenario = { id: "first-goal-agent-unavailable", async run({ browser, collectCoverage, url }) { @@ -135,7 +230,7 @@ export const firstGoalAgentUnavailableScenario = { const text = (await drawer.innerText()).replace(/\s+/g, " "); assert.match(text, /还不能开始这个 Goal/, `the owner reads which Agent cannot start the Goal: ${text.slice(0, 300)}`); assert.match(text, /本机没有找到命令 \/opt\/loopx\/bin\/codex/, "the catalog's own reason is shown"); - assert.match(text, /先安装并登录/, "the next action is stated"); + assert.match(text, /根据上述原因修复/, "the next action follows the reported availability fact"); assert.match(text, /没有写入任何 Goal/, "nothing was written"); assert.doesNotMatch(text, /endpoint is not healthy|not available to start/, "no protocol sentence reaches the owner"); await page.screenshot({ path: resolve(outputDir, "first-goal-agent-unavailable.png"), animations: "disabled" }); @@ -151,7 +246,10 @@ export const firstGoalAgentUnavailableScenario = { await drawer.waitFor({ state: "visible", timeout: 10_000 }); await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); const coverageEntries = await session.close(); - if (packaged) coverageEntries.push(...await realUnavailableAgent({ browser, collectCoverage, url })); + if (packaged) { + coverageEntries.push(...await realUnavailableAgent({ browser, collectCoverage, url })); + for (const locale of ["zh-CN", "en"]) coverageEntries.push(...await realRemoteMapping({ browser, collectCoverage, url, locale })); + } return { coverageEntries }; } catch (error) { await session.close().catch(() => {}); diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index e4f8f76d68..4a44503c62 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -441,3 +441,83 @@ def request(path, body=None): server.shutdown() thread.join(timeout=5) server.server_close() + + +@pytest.mark.parametrize("restriction,message", [ + ("tools", "selected Agent endpoint cannot execute Goal tools"), + ("trust", "selected Agent endpoint has an incompatible trust scope"), + ("mapping", "remote Agent endpoint needs a workspace mapping"), +]) +def test_available_first_agent_retains_its_real_eligibility_refusal(tmp_path, restriction, message): + import http.client + import sys + import threading + + from loopx.chat_actions import goal_workspace_ref + from loopx.chat_endpoints import AgentEndpointRegistry + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.chat_store import ChatSessionStore + + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx/registry.json" + runtime = tmp_path / "runtime" + sessions = ChatSessionStore(runtime) + endpoints = AgentEndpointRegistry(sessions.root) + endpoint = {"agent_id": "remote-first", "display_name": "Remote first Agent", + "command": [sys.executable], "location": "remote"} + endpoints.upsert(endpoint) + controller = ChatRuntimeController(store=sessions, endpoint_registry=endpoints, + codex_bin=str(tmp_path / "no-codex"), claude_bin=str(tmp_path / "no-claude"), + kiro_cli_bin=str(tmp_path / "no-kiro")) + native_capabilities = controller.capabilities + def capabilities(): + rows = native_capabilities() + for row in rows: + if row["agent_id"] == "remote-first": + if restriction == "tools": + row["tool_calls"] = False + if restriction == "trust": + row["trust_scope"] = "incompatible" + return rows + controller.capabilities = capabilities + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.runtime_root_override = registry, runtime, str(runtime) + server.action_store = ChatActionStore(runtime / "chat/actions") + server.action_service = ChatActionService(store=server.action_store, registry_path=registry, + chat_store=sessions, runtime_controller=controller, workspace_roots=[project]) + server.chat_store, server.runtime_controller, server.verbose = sessions, controller, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + request = {"action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": "same-first-request", + "context": {"kind": "manager", "goal_id": None}, + "normalized_parameters": {"goal_id": "goal-first", "title": "First Goal", "objective": "Start here", + "agent_id": "remote-first", "workspace_ref": goal_workspace_ref(project)}} + encoded = json.dumps(request) + def preview(): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + try: + connection.request("POST", "/api/actions/preview", encoded, {"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + try: + assert endpoints.get("remote-first").available() + status, rejected = preview() + assert status == 400, rejected + assert rejected["error"] == message and "gate" not in rejected + assert not registry.exists() and server.action_store.list() == [] + controller.capabilities = native_capabilities + endpoints.upsert({**endpoint, "workspace_mapping": [ + {"local_root": str(project), "agent_root": "/synthetic/workspace"}]}) + status, recovered = preview() + assert status == 201, recovered + assert recovered["proposal"]["normalized_parameters"]["agent_id"] == "remote-first" + assert len(server.action_store.list()) == 1 and not registry.exists() + assert sessions.list_sessions() == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() From 1b2af9d52838ed1f388d74f19c4a1f881f8599a0 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 21:49:13 +0800 Subject: [PATCH 4/5] docs(workspace): describe eligibility refusal and unchanged-request retry Signed-off-by: song --- docs/guides/personal-workspace-user-guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index ef55c5b535..ec11b104d5 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -103,6 +103,9 @@ Goal 是什么、一个 **创建 Goal** 按钮(打开与侧边栏 `+` 相同 时才显示健康告警,并以可读句子描述问题,原始检查行同样折叠在「诊断信息」里。本机还没有可用的 Agent(例如 Codex 未安装或未登录)时,「创建 Goal」的检查会直接告诉你是哪个 Agent、缺什么(命令 或环境变量)以及下一步怎么做,不会写入任何内容,表单内容也会保留。 +可用的 Agent 也可能因工具能力、信任范围或远程工作区映射而拒绝创建。此时表单显示实际原因, +不会把配置拒绝当成安装或登录问题。修复后可直接再次检查;未修改的内容及操作身份保持, +修改内容则形成新请求。检查仍不创建 Goal,只有原来的确认动作才能创建。 When no Goal exists yet, the overview shows a first-Goal state instead of empty lanes or a health banner: what a Goal is, a **Create a Goal** button (the same form as the sidebar `+`), @@ -110,6 +113,10 @@ and three example requests that only fill the composer. The status source's own lines (such as "no registry yet on this machine") stay collapsed under "Status source details". A health banner appears only when the source is actually unhealthy, in plain sentences, with the raw check lines collapsed under "Diagnostics". When this machine has no usable Agent yet (Codex not installed or not signed in), the Goal creation review names that Agent, what is missing (the command or the environment variable) and the next step; nothing is written and the form keeps what you typed. +An available Agent may still refuse creation because of tools, trust scope or a remote workspace +mapping. The form shows the actual reason instead of an installation or sign-in instruction. +Repair that configuration and review again: unchanged contents retain their request identity; +editing them starts a new request. Review creates no Goal; the original confirmation is still required. ### 核心功能区 1. **「你不在的时候」离线概览**: From de386d51e5fb07ab53c6af0be042ea50c5146c4c Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 21:04:36 +0800 Subject: [PATCH 5/5] test(architecture): reconcile the retired governance module ratchet Signed-off-by: song (cherry picked from commit 11966b2a08243688d30fca307a1ecb0b0add7c9a) Signed-off-by: song --- tests/architecture/top_level_module_budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/architecture/top_level_module_budget.json b/tests/architecture/top_level_module_budget.json index ba36f981a5..d6b72cf0c2 100644 --- a/tests/architecture/top_level_module_budget.json +++ b/tests/architecture/top_level_module_budget.json @@ -1,6 +1,6 @@ { "schema": "loopx_top_level_module_budget_v0", - "baseline_commit": "14112f36cfa56400851494e4236c84dd74538069", - "max_top_level_modules": 146, + "baseline_commit": "a4483aaece5bc74ec3f969fd067229487022401b", + "max_top_level_modules": 145, "allowlist": ["__init__.py", "entrypoint.py", "cli.py"] }