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..7c8225f59b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1188,11 +1188,11 @@ 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} - {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} + {!readOnly && selection.item.persisted !== false && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} {!["applied", "applying"].includes(selection.item.status) ? : null} ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-create-request.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-create-request.ts index 3b10663f5b..a405ec56e9 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-create-request.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-create-request.ts @@ -1,8 +1,16 @@ import type { WorkspaceTranslate } from "./i18n"; -import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model"; +import type { WorkspaceActionPreviewRequest, WorkspaceAgentOption } from "./personal-workspace-model"; type GoalContent = { objective: string; completion: string; boundary: string }; +/** A read-only conversation projection cannot be the first Goal's executor. */ +export function goalCreationAgentId(selectedId: string, agents: readonly WorkspaceAgentOption[]): string { + const selected = agents.find((agent) => agent.agentId === selectedId); + if (selected?.adapterKind !== "status_projection") return selectedId; + const executors = agents.filter((agent) => agent.adapterKind !== "status_projection"); + return executors.find((agent) => agent.available)?.agentId ?? executors[0]?.agentId ?? selectedId; +} + export function goalCreateContent(input: GoalContent, t: WorkspaceTranslate) { const objective = input.objective.trim(); const completion = input.completion.trim(); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index e562f0dd4b..84d0df7345 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1031,6 +1031,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": "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", "projection.agentNeedsDecision": "Agent is waiting for your decision", @@ -2579,6 +2587,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} 的可用性,再按最新状态重新检查。首页的运行时提示会显示当前检查结果。", + "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 73dc610423..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 @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { goalCreateRequest } from "./goal-create-request.ts"; +import { goalCreateRequest, goalCreationAgentId } from "./goal-create-request.ts"; import { localizedCapabilityFieldCopy } from "./capability-localization.ts"; import { taskSessionStatus } from "./goal-activity.ts"; import "./monitor-readback.test.mjs"; const source = (name) => readFileSync(new URL(name, import.meta.url), "utf8"); +const projectionAgent = { agentId: "status-only", adapterKind: "status_projection", available: true, label: "Status" }; +const missingAgent = { agentId: "codex", available: false, label: "Codex" }; +const availableAgent = { agentId: "claude", available: true, label: "Claude" }; +assert.equal(goalCreationAgentId("status-only", [missingAgent, projectionAgent]), "codex", "Creation diagnoses the real missing executor, never the read-only snapshot"); +assert.equal(goalCreationAgentId("status-only", [missingAgent, availableAgent, projectionAgent]), "claude", "Creation can use the discovered available executor"); +assert.equal(goalCreationAgentId("codex", [missingAgent, availableAgent, projectionAgent]), "codex", "An explicit unavailable executor remains selected for its own gate"); +assert.equal(goalCreationAgentId("custom", [missingAgent, projectionAgent]), "custom", "An unknown explicit identity is left for backend eligibility"); +assert.equal(goalCreationAgentId("status-only", [projectionAgent]), "status-only", "An empty executor catalog cannot manufacture an executor"); const answerText = source("./answer-text.ts"); const model = source("./personal-workspace-model.ts"); const activity = source("./goal-activity.ts"); @@ -692,3 +700,13 @@ 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-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 44f31faa32..d8eaf2c340 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 @@ -350,6 +350,8 @@ export type WorkspaceActionPreview = { // See proposal-recency.ts. createdAt?: string; previewId: string; + /** False for a refused preview held only in this page; no stored action exists to read back. */ + persisted?: boolean; regeneratedFrom?: string | null; primaryLabel?: string; errorMessage?: string; 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 02f0922f53..689ee4db52 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 @@ -1,6 +1,6 @@ import {useDeliveryReview} from "./delivery-review-state"; import {useGoalWorkspaceDigest} from "./goal-masthead"; -import { goalCreateRequest } from "./goal-create-request"; +import { goalCreateRequest, goalCreationAgentId } from "./goal-create-request"; import { todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback"; import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest } from "./steering-recovery"; import { useConversationInputState } from "./use-conversation-input-state"; @@ -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"; @@ -818,6 +817,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? t("proposal.impact.protected") : "", previewId: proposal.proposal_id, + persisted: true, regeneratedFrom: proposal.regenerated_from, lifecycleOperation, gate: proposal.gate ? { @@ -1040,6 +1040,7 @@ export function PersonalWorkspacePage({ const selectedGoalId = controlledGoalId === undefined ? localGoalId : controlledGoalId; const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; + const goalAgentId = goalCreationAgentId(selectedAgentId, agents); // Only execution runtimes count: the read-only status projection answers // questions but runs nothing, and the loading placeholder is not a fact. const runtimeReadiness = useMemo(() => deriveRuntimeReadiness(agents @@ -1080,14 +1081,14 @@ export function PersonalWorkspacePage({ // Source message + reviewed contents survive retry without merging distinct requests. if (!edit && !draft.question && draft.completion_criteria.trim()) { const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode( - JSON.stringify([draftId, draft, selectedAgentId, locale]))); + JSON.stringify([draftId, draft, goalAgentId, locale]))); const operationId = Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, "0")).join(""); await createPreview(goalCreateRequest({ objective: draft.objective, completion: draft.completion_criteria, boundary: draft.execution_boundary, - permission: "read_only", agentId: selectedAgentId, contextGoalId: null, operationId }, t)); + permission: "read_only", agentId: goalAgentId, contextGoalId: null, operationId }, t)); return; } - setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: selectedAgentId, + setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: goalAgentId, text: draft.objective, completionCriteria: draft.completion_criteria, executionBoundary: draft.execution_boundary, permission: "read_only" }); } @@ -1471,7 +1472,7 @@ export function PersonalWorkspacePage({ const creationProposalId = drawerSelection?.kind === "proposal" && drawerSelection.item.actionKind === "goal.create" - && !drawerSelection.item.previewId.startsWith("workspace-choice-") + && drawerSelection.item.persisted !== false ? drawerSelection.item.previewId : null; const proposalReadback = useTypedActionProposalReadback(readOnly, creationProposalId); useEffect(() => { @@ -1614,6 +1615,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) => ({ @@ -1623,16 +1642,17 @@ 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)}`, + persisted: false, 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, }; } @@ -1643,7 +1663,7 @@ export function PersonalWorkspacePage({ } function requestGoalCreate() { - setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: selectedAgentId }); + setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: goalAgentId }); } /** `keepGoalSelected` reads the result back on the Goal page instead of returning to the directory. */ @@ -2037,6 +2057,17 @@ export function PersonalWorkspacePage({ } }, onTransitionProposal: async (proposal, transition) => { + if (proposal.persisted === false) { + if (transition === "regenerate" && proposal.sourceRequest) { + await createPreview(proposal.sourceRequest); + setProposals((current) => { + const next = { ...current }; + delete next[proposal.previewId]; + return next; + }); + } + return; + } const result = await transitionTypedAction(proposal.previewId, transition); await actionReadback.acceptProposal(result, transition === "regenerate" ? proposal.previewId : undefined); const transitioned = workspaceProposal(result, t); @@ -2500,7 +2531,8 @@ export function PersonalWorkspacePage({ todoReadbackUnavailable={drawerSelection.kind === "todo" && !workspaceGoals.some((goal) => goal.goalId === drawerSelection.item.goalId && !goal.loadState && (drawerSelection.item.detailMode !== undefined || drawerSelection.item.done || goal.agentTodos.some((todo) => todo.todoId === drawerSelection.item.todoId)))} - proposalReadbackUnavailable={creationProposalId ? proposalReadback.isError || !proposalReadback.data + proposalReadbackUnavailable={drawerSelection.kind === "proposal" && drawerSelection.item.persisted === false ? false + : creationProposalId ? proposalReadback.isError || !proposalReadback.data : actionReadback.isError || !actionReadback.data || (drawerSelection.kind === "proposal" && !actionReadback.data.some(proposal => proposal.proposal_id === drawerSelection.item.previewId))} proposalReadbackFetching={creationProposalId ? proposalReadback.isFetching : actionReadback.isFetching} 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/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 988bf306e0..ec11b104d5 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -100,14 +100,23 @@ graph TD 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 `+`), 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. +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. **「你不在的时候」离线概览**: diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index d5166c1588..55c145e60b 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 {runtimeReadinessScenario} from "./personal-workspace-browser/runtime-readiness.mjs"; import {runtimeReadinessLiveScenario} from "./personal-workspace-browser/runtime-readiness-live.mjs"; import {attentionNotificationsScenario} from "./personal-workspace-browser/attention-notifications.mjs"; @@ -122,6 +123,7 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(firstGoalAgentUnavailableScenario); scenarioCatalog.push(runtimeReadinessScenario); scenarioCatalog.push(runtimeReadinessLiveScenario); scenarioCatalog.push(attentionNotificationsScenario); 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..8c301d46f6 --- /dev/null +++ b/examples/personal-workspace-browser/first-goal-agent-unavailable.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +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"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { waitForHttp } from "../dashboard-browser-smoke-support.mjs"; +import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, 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"] }; +} + +async function realUnavailableAgent({ browser, collectCoverage, url }) { + const root = await mkdtemp(join(tmpdir(), "loopx-first-goal-no-agent-")); + 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"); + const 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(`fresh-install backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + await waitForHttp(`${backend}/status.json?view=workspace-directory`); + const catalog = await (await fetch(`${backend}/api/chat/capabilities`)).json(); + assert.equal(catalog.adapters.find((row) => row.agent_id === "codex").available, false); + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + 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) => { + const response = await fetchWorkspaceBackend(route, backend); + await route.fulfill({ response }); + }); + } }); + const { page } = session; + const requests = []; + const previews = []; + page.on("request", (request) => { + if (request.method() === "POST") requests.push(new URL(request.url()).pathname); + if (request.method() === "POST" && new URL(request.url()).pathname === "/api/actions/preview") previews.push(request.postDataJSON()); + }); + const firstGoal = page.getByTestId("personal-first-goal"); + await firstGoal.waitFor({ state: "visible", timeout: 20_000 }); + const title = "Track the weekly release checklist"; + async function preview() { + await firstGoal.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); + await form.waitFor({ state: "visible" }); + await form.getByLabel("目标").fill(title); + await form.getByLabel("完成标准").fill("A checklist exists and is reviewed every week"); + await form.locator("button[type=submit]").click(); + } + await preview(); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 20_000 }); + const text = (await drawer.innerText()).replace(/\s+/g, " "); + assert.match(text, /还不能开始这个 Goal/); + assert.match(text, /codex/i); + 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"); + assert.equal(await drawer.getByRole("button", { name: "稍后", exact: true }).count(), 0, "No persisted defer/reject operation exists for a refused preview"); + assert.equal(await drawer.getByRole("button", { name: "拒绝", exact: true }).count(), 0); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-agent-unavailable-live-phone.png"), animations: "disabled" }); + const registry = join(ready.project, ".loopx", "registry.json"); + await assert.rejects(stat(registry), { code: "ENOENT" }); + assert.deepEqual((await (await fetch(`${backend}/status.json?view=workspace-directory`)).json()).goals, []); + + // Only a temporary exit-only executable becomes available. Preview never + // applies a Goal or starts a Turn; real credentials/binaries stay isolated. + await writeFile(join(ready.bins, "codex"), "#!/bin/sh\nexit 0\n"); + await chmod(join(ready.bins, "codex"), 0o755); + await drawer.getByRole("button", { name: "按最新状态重新检查", exact: true }).click(); + await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); + await assert.rejects(stat(registry), { code: "ENOENT" }); + assert.equal(requests.filter((path) => path === "/api/actions/preview").length, 2); + assert.equal(previews[0].normalized_parameters.title, title); + assert.deepEqual(previews[1], previews[0], "Rechecking retains the owner's original draft and operation identity"); + assert.ok(requests.every((path) => !/\/apply$|\/turns$/.test(path)), "preview recovery grants no apply or Turn"); + return await session.close(); + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + 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); } } + } +} + + +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 }) { + 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 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" }); + 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 }); + const coverageEntries = await session.close(); + 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(() => {}); + throw error; + } + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 39a6c13715..015275cd42 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, @@ -1894,6 +1897,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/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py index 6fac32c3e8..73a3cbcd82 100644 --- a/examples/personal-workspace-browser/fresh-install-backend.py +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -26,8 +26,9 @@ (root / "project").mkdir(parents=True, exist_ok=True) bins = root / "bin" bins.mkdir(exist_ok=True) -(bins / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") -(bins / "codex").chmod(0o755) +if "--no-agent" not in sys.argv[2:]: + (bins / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (bins / "codex").chmod(0o755) for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): os.environ.pop(variable, None) os.environ["LOOPX_USAGE_PING"] = "0" @@ -40,6 +41,7 @@ "project": str(root / "project"), "runtime": str(root / "runtime"), "default_runtime": str(home / ".loopx"), + "bins": str(bins), }), flush=True) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) serve_chat( 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 a528e36bf3..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): @@ -319,6 +331,38 @@ 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]: + """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 + 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 _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": "Resolve the reported availability issue, then preview this request 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, @@ -345,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"}: @@ -1210,7 +1254,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/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"] } diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index 49ed2b8d73..4a44503c62 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -362,3 +362,162 @@ def entries(root): server.shutdown() thread.join(timeout=5) server.server_close() + + +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() + + +@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()