diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 241b6c2819..93df623143 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -21,7 +21,7 @@ "smoke:benchmark-study": "rm -rf node_modules/.cache/loopx-benchmark-study-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --resolveJsonModule --outDir node_modules/.cache/loopx-benchmark-study-smoke smoke/benchmark-study-contract-smoke.ts src/data/benchmark-study.ts && node node_modules/.cache/loopx-benchmark-study-smoke/smoke/benchmark-study-contract-smoke.js", "smoke:benchmark-study-browser": "node ../../../examples/dashboard-benchmark-study-browser-smoke.mjs", "smoke:capability-configuration": "rm -rf node_modules/.cache/loopx-capability-configuration-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --skipLibCheck --strict --outDir node_modules/.cache/loopx-capability-configuration-smoke smoke/capability-configuration-smoke.ts src/data/capability-configuration.ts && node node_modules/.cache/loopx-capability-configuration-smoke/smoke/capability-configuration-smoke.js", - "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts src/vite-env.d.ts && node node_modules/.cache/loopx-chat-route-smoke/smoke/chat-route-smoke.js", + "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts src/vite-env.d.ts && node node_modules/.cache/loopx-chat-route-smoke/apps/presentation/dashboard/smoke/chat-route-smoke.js", "smoke:chat-stream-stall": "vite build --ssr smoke/chat-stream-stall-smoke.ts --outDir node_modules/.cache/loopx-chat-stream-stall --emptyOutDir && node node_modules/.cache/loopx-chat-stream-stall/chat-stream-stall-smoke.js", "smoke:chat-turn-acceptance-retry": "vite build --ssr smoke/chat-turn-acceptance-retry-smoke.ts --outDir node_modules/.cache/loopx-chat-turn-acceptance-retry --emptyOutDir && node node_modules/.cache/loopx-chat-turn-acceptance-retry/chat-turn-acceptance-retry-smoke.js", "smoke:demo-readiness": "bash ../../../scripts/loopx-python.sh --exec ../../../examples/dashboard-demo-readiness-smoke.py", @@ -38,10 +38,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", - "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", diff --git a/apps/presentation/dashboard/smoke/chat-route-smoke.ts b/apps/presentation/dashboard/smoke/chat-route-smoke.ts index a9503c31c4..e30b7f46ca 100644 --- a/apps/presentation/dashboard/smoke/chat-route-smoke.ts +++ b/apps/presentation/dashboard/smoke/chat-route-smoke.ts @@ -46,7 +46,7 @@ type ChatRouteCandidate = { }; const capabilityAwareRoute = chatModel as unknown as { - selectAvailableChatAgent?: ( + selectChatAgent?: ( options: ChatRouteCandidate[], preferredAgentId: string | undefined, defaultAgentId: string, @@ -54,7 +54,7 @@ const capabilityAwareRoute = chatModel as unknown as { }; check( - typeof capabilityAwareRoute.selectAvailableChatAgent === "function", + typeof capabilityAwareRoute.selectChatAgent === "function", "Chat routing should expose stale-selection fallback", ); @@ -64,17 +64,25 @@ const routeOptions: ChatRouteCandidate[] = [ { agentId: "status-only", available: true }, ]; check( - capabilityAwareRoute.selectAvailableChatAgent!(routeOptions, "registered-fixer", "codex").agentId === "codex", + capabilityAwareRoute.selectChatAgent!(routeOptions, "registered-fixer", "codex").agentId === "codex", "a persisted unavailable Agent should fall back to Codex", ); check( - capabilityAwareRoute.selectAvailableChatAgent!( + capabilityAwareRoute.selectChatAgent!( routeOptions.map((option) => option.agentId === "codex" ? { ...option, available: false } : option), "registered-fixer", "status-only", ).agentId === "status-only", "Chat should fall back to status-only when no model adapter is available", ); +const unavailableProjectOptions = routeOptions.filter(option => option.agentId !== "status-only") + .map(option => ({ ...option, available: false })); +const diagnosticRoute = capabilityAwareRoute.selectChatAgent!(unavailableProjectOptions, "registered-fixer", "codex"); +check(diagnosticRoute.agentId === "registered-fixer" && diagnosticRoute.available === false, + "An unavailable project keeps the preferred identity and its refusal, rather than crashing or granting execution"); +const missingPreferred = capabilityAwareRoute.selectChatAgent!(unavailableProjectOptions, "status-only", "status-only"); +check(missingPreferred.agentId === "codex" && missingPreferred.available === false, + "A project without status projection keeps an actual unavailable executor, never a fabricated readonly route"); const fixture: ChatStatus = { ok: true, diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 8807628af7..0bafe53d7a 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -109,7 +109,8 @@ export type ChatRouteCandidate = { available: boolean; }; -export function selectAvailableChatAgent( +/** Prefer an executable route; keep an unavailable candidate for diagnosis when none can run. */ +export function selectChatAgent( options: T[], preferredAgentId: string | undefined, defaultAgentId: string, @@ -118,9 +119,12 @@ export function selectAvailableChatAgent( option.agentId === preferredAgentId && option.available ) ?? options.find((option) => option.agentId === defaultAgentId && option.available - ) ?? options.find((option) => option.available); + ) ?? options.find((option) => option.available) + ?? options.find((option) => option.agentId === preferredAgentId) + ?? options.find((option) => option.agentId === defaultAgentId) + ?? options[0]; if (!selected) { - throw new Error("Chat requires at least one available route"); + throw new Error("Chat requires at least one route"); } return selected; } diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index ed43a3d6c2..161c8c5991 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -56,7 +56,7 @@ export { pendingGoalReviews, proposalReviewState, sessionInvalidatedByPayload, - selectAvailableChatAgent, + selectChatAgent, selectChatGoal, stewardPrompts, turnReplaySafeByPayload, @@ -205,6 +205,10 @@ export const chatCapabilitiesSchema = z.object({ source: z.string().optional(), tool_calls: z.boolean().optional(), trust_scope: z.string().optional(), + // Readiness: why an unavailable built-in runtime is unavailable and what it needs. + unavailable_reason: z.string().nullable().optional(), + executable: z.string().nullable().optional(), + environment_variable: z.string().nullable().optional(), })).optional(), lark_cli: z.object({ available: z.boolean(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 6a305b4e72..e562f0dd4b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -586,6 +586,23 @@ const en = { "files.title": "Files & Outputs", "files.verifiedReport": "Verified milestone report", "header.agentUnavailable": "Unavailable", + "agent.statusAvailable": "Available", + "agent.statusDetecting": "Detecting…", + "agent.statusNeedsSetup": "Needs setup", + "agent.workspaceWriteVerified": "Verified before this Goal writes", + "agent.workspaceUnavailable": "Unavailable; repair the runtime first", + "readiness.noneTitle": "No Agent runtime is ready on this machine", + "readiness.noneLead": "LoopX drives Agents through a command-line tool installed here, such as Codex, Claude Code or Kiro CLI, or through an API key. Install and sign in to one, then check again. Nothing is installed or granted from this page.", + "readiness.missingExecutable": "{label}: command “{name}” was not found on PATH", + "readiness.missingEnvironment": "{label}: environment variable {name} is not set", + "readiness.missingOther": "{label}: not available ({reason})", + "readiness.missingUnknown": "{label}: not available", + "readiness.selectedUnavailable": "{label} is not available on this machine. Install and sign in, then check again, or pick another runtime.", + "readiness.selectedUnavailableNone": "Agent execution is not ready on this machine. Set up an execution runtime first.", + "readiness.selectedUnavailableReadonly": "Agent execution is not ready. Status only can still answer questions about existing work.", + "readiness.recheck": "Check again", + "readiness.rechecking": "Checking…", + "readiness.guide": "Setup guide", "header.chatRuntime": "Chat", "header.workAgentCount": "{count} work Agents", "header.overview": "Overview", @@ -2117,6 +2134,23 @@ const zhCN: Record = { "files.title": "Files & Outputs", "files.verifiedReport": "已验证阶段周报", "header.agentUnavailable": "不可用", + "agent.statusAvailable": "可用", + "agent.statusDetecting": "正在检测", + "agent.statusNeedsSetup": "需要配置", + "agent.workspaceWriteVerified": "当前 Goal 写入前验证", + "agent.workspaceUnavailable": "不可用,需先修复运行环境", + "readiness.noneTitle": "这台机器上还没有可用的 Agent 运行环境", + "readiness.noneLead": "LoopX 通过本机已安装的命令行工具驱动 Agent,例如 Codex、Claude Code 或 Kiro CLI,也可以通过 API 密钥。安装并登录其中一个后,点「重新检查」。本页不会安装任何东西,也不授予任何权限。", + "readiness.missingExecutable": "{label}:未在 PATH 中找到命令「{name}」", + "readiness.missingEnvironment": "{label}:未设置环境变量 {name}", + "readiness.missingOther": "{label}:不可用({reason})", + "readiness.missingUnknown": "{label}:不可用", + "readiness.selectedUnavailable": "{label} 在这台机器上不可用。安装并登录后重新检查,或选择其他运行环境。", + "readiness.selectedUnavailableNone": "Agent 执行尚未就绪,请先完成执行运行环境设置。", + "readiness.selectedUnavailableReadonly": "Agent 执行尚未就绪;仍可使用「仅查状态」查询已有工作。", + "readiness.recheck": "重新检查", + "readiness.rechecking": "正在检查…", + "readiness.guide": "安装指引", "header.chatRuntime": "Chat", "header.workAgentCount": "{count} 个工作 Agent", "header.overview": "概览", 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 3fc1a60c18..73dc610423 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 @@ -600,6 +600,19 @@ for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoa assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } +// Runtime readiness: the service's own reason fields drive the copy; the +// read-only status projection and the loading placeholder never count as a +// runtime; a re-check reads the catalog again and nothing is installed. +const runtimeReadiness = source("./runtime-readiness.ts"); +assert.match(runtimeReadiness, /if \(agents\.length === 0\) return \{ kind: "detecting" \};/, "An empty catalog is detecting, not absence"); +assert.match(page, /agent\.adapterKind !== "status_projection" && agent\.source !== "detecting"/, "Readiness ignores the status projection and the loading placeholder"); +assert.match(dashboard, /onRecheckRuntimes: \(\) => setCapabilityRevision\(\(revision\) => revision \+ 1\)/, "Re-check re-reads the capabilities catalog"); +assert.match(dashboard, /source: "detecting",/, "The placeholder row is typed as detecting"); +assert.doesNotMatch(dashboard, /statusLabel: "正在检测"|"可用" : "需要配置"|当前 Goal 写入前验证/, "Agent status labels are localized"); +for (const key of ["readiness.noneTitle", "readiness.missingExecutable", "readiness.missingEnvironment", "readiness.selectedUnavailable", "readiness.recheck", "agent.statusDetecting"]) { + assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); +} + // Needs-you desktop notifications: a per-browser opt-in. Off by default, the // page neither asks for permission nor re-reads the workspace in the background. const attentionNotifications = source("./attention-notifications.ts"); 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 358774771f..44f31faa32 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 @@ -483,6 +483,10 @@ export type WorkspaceAgentOption = { agentId: string; available: boolean; capability?: string; + /** Service-reported readiness for an unavailable runtime; see runtime-readiness.ts. */ + unavailableReason?: string | null; + executable?: string | null; + environmentVariable?: string | null; interrupt?: boolean; label: string; location?: string; @@ -569,6 +573,8 @@ export type PersonalWorkspaceCallbacks = { onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise; /** Full refresh by default; error recovery can explicitly read missing Goals only. */ onRefresh?: (scope?: WorkspaceReadScope) => void | WorkspaceRefreshResult | Promise; + /** Re-read the Chat service's runtime catalog after the owner repaired something. */ + onRecheckRuntimes?: () => void; /** Re-read the workspace without a loading state; used by background freshness such as notifications. */ onBackgroundRefresh?: () => void | Promise; onRetryGoalArchive?: () => void | Promise; 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 21abec98d6..02f0922f53 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 @@ -101,6 +101,7 @@ import { type AttentionNotificationSupport, } from "./attention-notifications"; import { compareProposalRecency } from "./proposal-recency"; +import { deriveRuntimeReadiness, runtimeRequirement, runtimeSetupGuideUrl, type RuntimeAgentReadiness, type RuntimeReadiness } from "./runtime-readiness"; import { buildSeenMarker, compareSeenMarker, readSeenMarker, seenMarkerStorage, seenMarkerStorageKey, writeSeenMarker, type SeenMarkerInput, type SinceYouLookedDelta } from "./since-you-looked"; import { deriveGoalSuggestions, type GoalSuggestion } from "./goal-suggestions"; import { projectGoalAcceptanceCriteria } from "../../data/goal-acceptance-observation"; @@ -133,6 +134,30 @@ function activityTimeLabel(value: string | undefined, locale: string, t: Workspa return new Intl.DateTimeFormat(locale, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); } +function runtimeMissingLine(agent: RuntimeAgentReadiness, t: WorkspaceTranslate): string { + const requirement = runtimeRequirement(agent); + if (requirement.kind === "executable") return t("readiness.missingExecutable", { label: agent.label, name: requirement.name }); + if (requirement.kind === "environment_variable") return t("readiness.missingEnvironment", { label: agent.label, name: requirement.name }); + return requirement.reason ? t("readiness.missingOther", { label: agent.label, reason: requirement.reason }) : t("readiness.missingUnknown", { label: agent.label }); +} + +/** The home's in-place readiness notice: what is missing, where to set it up, and a re-check that reads back. */ +function RuntimeReadinessNotice({ readiness, rechecking, onRecheck }: { readiness: RuntimeReadiness; rechecking: boolean; onRecheck?: () => void }) { + const { t } = useWorkspaceI18n(); + if (readiness.kind !== "none_available") return null; + return ( +
+

{t("readiness.noneTitle")}

+

{t("readiness.noneLead")}

+
    {readiness.missing.map((agent) =>
  • {runtimeMissingLine(agent, t)}
  • )}
+
+ {onRecheck ? : null} + {t("readiness.guide")} +
+
+ ); +} + function ManagerHomeBoard({ goals, onSelectGoal, @@ -141,6 +166,9 @@ function ManagerHomeBoard({ onboarding, onRequestGoalCreate, onSuggestRequest, + runtimeReadiness, + recheckingRuntimes, + onRecheckRuntimes, systemHealth, operations, onSelectOperation, @@ -154,6 +182,9 @@ function ManagerHomeBoard({ onboarding?: WorkspaceHomeOnboarding; onRequestGoalCreate?: () => void; onSuggestRequest?: (text: string) => void; + runtimeReadiness?: RuntimeReadiness; + recheckingRuntimes?: boolean; + onRecheckRuntimes?: () => void; systemHealth?: WorkspaceSystemHealth; operations: WorkspaceActionPreview[]; onSelectOperation: (proposal: WorkspaceActionPreview) => void; @@ -188,6 +219,7 @@ function ManagerHomeBoard({ ); return (
+ {runtimeReadiness ? : null} {systemHealth && (!systemHealth.ok || systemHealth.issues.length > 0 || systemHealth.freshnessWarning) ? (
@@ -1008,6 +1040,27 @@ export function PersonalWorkspacePage({ const selectedGoalId = controlledGoalId === undefined ? localGoalId : controlledGoalId; const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; + // 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 + .filter((agent) => agent.adapterKind !== "status_projection" && agent.source !== "detecting") + .map((agent) => ({ + agentId: agent.agentId, label: agent.label, available: agent.available, + unavailableReason: agent.unavailableReason ?? null, executable: agent.executable ?? null, environmentVariable: agent.environmentVariable ?? null, + })), selectedAgentId), [agents, selectedAgentId]); + // A re-check reads the runtime catalog again; the answer arrives as new agents. + const [recheckingRuntimes, setRecheckingRuntimes] = useState(false); + useEffect(() => { setRecheckingRuntimes(false); }, [agents]); + useEffect(() => { + if (!recheckingRuntimes) return; + const timer = window.setTimeout(() => setRecheckingRuntimes(false), 8_000); + return () => window.clearTimeout(timer); + }, [recheckingRuntimes]); + function recheckRuntimes() { + if (!callbacks.onRecheckRuntimes) return; + setRecheckingRuntimes(true); + callbacks.onRecheckRuntimes(); + } const conversationKey = selectedWorkspaceRef ? conversationContextKey({ kind: "project", projectRef: selectedWorkspaceRef }) : selectedGoalId ?? "manager"; @@ -2610,6 +2663,7 @@ export function PersonalWorkspacePage({ ) : !managerChatOpen ? ( void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth} onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply} + runtimeReadiness={readOnly ? undefined : runtimeReadiness} recheckingRuntimes={recheckingRuntimes} onRecheckRuntimes={callbacks.onRecheckRuntimes ? recheckRuntimes : undefined} operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})} onViewAllOperations={() => setSelectedGoalTab("chat")} /> ) : ( @@ -2712,6 +2766,14 @@ export function PersonalWorkspacePage({ ))}
: null} {imageAttachmentError ?

{imageAttachmentError}

: null} + {!readOnly && (selectedGoal || managerChatOpen) && (runtimeReadiness.kind === "none_available" || runtimeReadiness.kind === "selected_unavailable") ? ( +

+ {runtimeReadiness.kind === "none_available" ? t(agents.some((agent) => agent.adapterKind === "status_projection" && agent.available) + ? "readiness.selectedUnavailableReadonly" : "readiness.selectedUnavailableNone") : t("readiness.selectedUnavailable", { label: runtimeReadiness.selected.label })} + {callbacks.onRecheckRuntimes ? : null} + {t("readiness.guide")} +

+ ) : null} {conversationTurnRunning ?

{steeringTurnId ? (locale === "zh-CN" ? "本轮进行中 · 发消息可调整当前工作" : "Turn in progress · send instructions to adjust this work") : t("composer.turnRunning")}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index de2222b052..a48aa2b6ec 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -332,6 +332,20 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-system-health-diagnostics { margin-top: 6px; font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-system-health-diagnostics summary { cursor: pointer; } .personal-system-health-diagnostics ul { margin: 4px 0 0 16px; padding: 0; } +.personal-runtime-readiness { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; max-width: 640px; min-width: 0; padding: 14px 16px; border: 1px solid var(--pw-amber); border-radius: 12px; background: var(--pw-amber-bg); color: var(--pw-text); } +.personal-runtime-readiness h2 { margin: 0; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; } +.personal-runtime-readiness p { margin: 0; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-runtime-readiness ul { margin: 0; padding-left: 18px; font-size: var(--text-caption); line-height: var(--leading-caption); } +/* A probed executable path is one long token; it must wrap inside the card on a phone instead of widening the track. */ +.personal-runtime-readiness > *, .personal-runtime-readiness li { min-width: 0; overflow-wrap: anywhere; } +.personal-runtime-readiness code { overflow-wrap: anywhere; word-break: break-all; } +.personal-runtime-readiness-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.personal-runtime-readiness-actions button { min-height: 36px; padding: 0 14px; border: 1px solid var(--pw-amber); border-radius: 8px; background: var(--pw-card); color: var(--pw-amber); font-size: var(--text-caption); font-weight: 600; cursor: pointer; } +.personal-runtime-readiness-actions button:disabled { opacity: .6; cursor: wait; } +.personal-runtime-readiness-actions a { color: var(--pw-blue-ink); font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-runtime-readiness-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; overflow-wrap: anywhere; color: var(--pw-amber); } +.personal-runtime-readiness-line button { min-height: 28px; padding: 0 10px; border: 1px solid var(--pw-amber); border-radius: 6px; background: transparent; color: var(--pw-amber); font-size: var(--text-caption); cursor: pointer; } +.personal-runtime-readiness-line a { color: var(--pw-blue-ink); } .personal-home-lane { display: flex; flex-direction: column; min-width: 0; min-height: 100%; border: 1px solid var(--pw-line); border-radius: 16px; background: #fff; box-shadow: 0 1px 4px rgb(30 28 20 / 4%); } .personal-manager-greeting > span { display: grid; flex: none; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--pw-blue-soft); color: var(--pw-blue); } .personal-manager-greeting div { display: grid; gap: 4px; min-width: 0; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.test.mjs new file mode 100644 index 0000000000..2cf166adcc --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveRuntimeReadiness, runtimeRequirement } from "./runtime-readiness.ts"; + +const codex = { agentId: "codex", label: "Codex", available: true, executable: "codex" }; +const codexMissing = { ...codex, available: false, unavailableReason: "executable_not_found" }; +const claudeMissing = { agentId: "claude-code", label: "Claude Code", available: false, unavailableReason: "executable_not_found", executable: "claude" }; +const apiMissing = { agentId: "anthropic-api", label: "Claude API", available: false, unavailableReason: "api_key_not_set", environmentVariable: "ANTHROPIC_API_KEY" }; + +test("an empty list is still detecting, never evidence of absence", () => { + assert.deepEqual(deriveRuntimeReadiness([], "codex"), { kind: "detecting" }); +}); + +test("nothing available lists every runtime as missing", () => { + const readiness = deriveRuntimeReadiness([codexMissing, claudeMissing, apiMissing], "codex"); + assert.equal(readiness.kind, "none_available"); + assert.deepEqual(readiness.missing.map((row) => row.agentId), ["codex", "claude-code", "anthropic-api"]); +}); + +test("a selected runtime that is not available names the alternatives", () => { + const readiness = deriveRuntimeReadiness([codexMissing, { ...claudeMissing, available: true }], "codex"); + assert.equal(readiness.kind, "selected_unavailable"); + assert.equal(readiness.selected.agentId, "codex"); + assert.deepEqual(readiness.alternatives.map((row) => row.agentId), ["claude-code"]); +}); + +test("an available selection, or no selection with something available, is ready", () => { + assert.deepEqual(deriveRuntimeReadiness([codex, claudeMissing], "codex"), { kind: "ready", selected: codex }); + assert.deepEqual(deriveRuntimeReadiness([codex, claudeMissing], null), { kind: "ready", selected: null }); + assert.deepEqual(deriveRuntimeReadiness([codex], "unknown-agent"), { kind: "ready", selected: null }, "an unknown selection does not block"); +}); + +test("requirements are named from the service's reason, never guessed", () => { + assert.deepEqual(runtimeRequirement(codexMissing), { kind: "executable", name: "codex" }); + assert.deepEqual(runtimeRequirement(apiMissing), { kind: "environment_variable", name: "ANTHROPIC_API_KEY" }); + assert.deepEqual(runtimeRequirement({ ...claudeMissing, unavailableReason: "host offline", executable: null }), { kind: "other", reason: "host offline" }); + assert.deepEqual(runtimeRequirement({ ...claudeMissing, unavailableReason: null }), { kind: "other", reason: null }); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.ts b/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.ts new file mode 100644 index 0000000000..5f57977025 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/runtime-readiness.ts @@ -0,0 +1,46 @@ +/** + * Readiness of the Agent runtimes the Chat service can drive. + * + * Availability is the service's live probe. This module only classifies it for + * the owner: nothing is ready yet, the runtime they picked is not available + * here, or they can proceed. Unknown stays "detecting"; an empty list is not + * evidence that nothing is installed. + */ + +export type RuntimeAgentReadiness = { + agentId: string; + label: string; + available: boolean; + unavailableReason?: string | null; + executable?: string | null; + environmentVariable?: string | null; +}; + +export type RuntimeReadiness = + | { kind: "detecting" } + | { kind: "ready"; selected: RuntimeAgentReadiness | null } + | { kind: "selected_unavailable"; selected: RuntimeAgentReadiness; alternatives: RuntimeAgentReadiness[] } + | { kind: "none_available"; missing: RuntimeAgentReadiness[] }; + +export function deriveRuntimeReadiness(agents: readonly RuntimeAgentReadiness[], selectedAgentId: string | null | undefined): RuntimeReadiness { + if (agents.length === 0) return { kind: "detecting" }; + const available = agents.filter((agent) => agent.available); + if (available.length === 0) return { kind: "none_available", missing: [...agents] }; + const selected = agents.find((agent) => agent.agentId === selectedAgentId) ?? null; + if (selected && !selected.available) return { kind: "selected_unavailable", selected, alternatives: available }; + return { kind: "ready", selected }; +} + +/** What the owner has to supply for one runtime, in a form copy can name. */ +export type RuntimeRequirement = + | { kind: "executable"; name: string } + | { kind: "environment_variable"; name: string } + | { kind: "other"; reason: string | null }; + +export function runtimeRequirement(agent: RuntimeAgentReadiness): RuntimeRequirement { + if (agent.unavailableReason === "executable_not_found" && agent.executable) return { kind: "executable", name: agent.executable }; + if (agent.unavailableReason === "api_key_not_set" && agent.environmentVariable) return { kind: "environment_variable", name: agent.environmentVariable }; + return { kind: "other", reason: agent.unavailableReason ?? null }; +} + +export const runtimeSetupGuideUrl = "https://github.com/loopx-project/loopx/blob/main/docs/guides/getting-started.md#codex-app-and-other-agent-setup"; diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index bd9f064f12..d4eac6c128 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -67,7 +67,7 @@ import { sendChatTurnStreaming, chatSessionQueuesFollowUps, chatSessionSupportsSteering, - selectAvailableChatAgent, + selectChatAgent, sessionInvalidatedByPayload, isTodoProposal, type ChatSessionSnapshot, @@ -557,6 +557,9 @@ type PersonalAgentOption = { agentId: string; available: boolean; capability: string; + unavailableReason?: string | null; + executable?: string | null; + environmentVariable?: string | null; interrupt?: boolean; label: string; location?: string; @@ -1270,6 +1273,9 @@ function PersonalGoalHome({ streaming: boolean; tool_calls?: boolean; trust_scope?: string; + unavailable_reason?: string | null; + executable?: string | null; + environment_variable?: string | null; }>>([]); const [goalSubagentConfigurationEnabled, setGoalSubagentConfigurationEnabled] = useState(false); const [managerRuntime, setManagerRuntime] = useState(null); @@ -1348,7 +1354,10 @@ function PersonalGoalHome({ location: agent.location, resume: agent.resume, source: agent.source, - statusLabel: agent.available ? "可用" : "需要配置", + statusLabel: agent.available ? t("agent.statusAvailable") : t("agent.statusNeedsSetup"), + unavailableReason: agent.unavailable_reason ?? null, + executable: agent.executable ?? null, + environmentVariable: agent.environment_variable ?? null, streaming: agent.streaming, toolCalls: agent.tool_calls, trustScope: agent.trust_scope, @@ -1358,7 +1367,9 @@ function PersonalGoalHome({ available: true, capability: personalAgentCapability("codex"), label: "Codex", - statusLabel: "正在检测", + // A placeholder while the catalog loads; readiness must not read it as a fact. + source: "detecting", + statusLabel: t("agent.statusDetecting"), }]; // The status projection answers about Goals; a workspace conversation needs an executor. const agentOptions = [ @@ -1395,7 +1406,7 @@ function PersonalGoalHome({ const [selectedAgents, setSelectedAgents] = useState>(readPersonalAgentSelections); const [selectedAgentModels, setSelectedAgentModels] = useState>({}); const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId); - const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId); + const selectedAgent = selectChatAgent(agentOptions, selectedAgentId, defaultAgentId); const [messagesByContext, setMessagesByContext] = useState>({}); const [runtimeBindings, setRuntimeBindings] = useState>({}); // Bound Sessions whose mode queues a message sent while a Turn runs, read @@ -2164,8 +2175,9 @@ function PersonalGoalHome({ ? model.goals.find((goal) => goal.goalId === targetContext.goalId) ?? null : null; const selectedRoute = route?.agentId - ? selectAvailableChatAgent(agentOptions, route.agentId, defaultAgentId) + ? selectChatAgent(agentOptions, route.agentId, defaultAgentId) : selectedAgent; + if (!selectedRoute.available) throw new Error(t("agent.workspaceUnavailable")); const targetQuestionModel = targetContextId === "manager" ? model : targetGoal @@ -2771,7 +2783,10 @@ function PersonalGoalHome({ streaming: agent.streaming, toolCalls: agent.toolCalls, trustScope: agent.trustScope, - workspaceCompatibility: agent.available ? "当前 Goal 写入前验证" : "不可用,需先修复 Endpoint", + unavailableReason: agent.unavailableReason, + executable: agent.executable, + environmentVariable: agent.environmentVariable, + workspaceCompatibility: agent.available ? t("agent.workspaceWriteVerified") : t("agent.workspaceUnavailable"), }))} callbacks={{ onApplyAttention: (attention) => openGoalChat(attention.goalId), @@ -3000,6 +3015,7 @@ function PersonalGoalHome({ setCapabilityRevision((revision) => revision + 1); return result; }, + onRecheckRuntimes: () => setCapabilityRevision((revision) => revision + 1), onBackgroundRefresh, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, diff --git a/docs/assets/personal-workspace/runtime-readiness-none.png b/docs/assets/personal-workspace/runtime-readiness-none.png new file mode 100644 index 0000000000..0519e022eb Binary files /dev/null and b/docs/assets/personal-workspace/runtime-readiness-none.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index b0bba7fe55..988bf306e0 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -44,6 +44,29 @@ curl -fsS http://127.0.0.1:8767/status.json --- +### 1.1 Agent 运行环境未就绪 / When no Agent runtime is ready + +![没有可用 Agent 运行环境时的管家总览](../assets/personal-workspace/runtime-readiness-none.png) + +LoopX 通过本机已安装的命令行工具驱动 Agent(Codex、Claude Code、Kiro CLI),或通过 API 密钥。 +如果这台机器上一个都没有就绪,管家总览会在顶部说明原因,逐项列出缺什么(未在 PATH 中找到的命令、 +未设置的环境变量),并提供「重新检查」和「安装指引」。安装并登录后点「重新检查」,页面重新读取 +服务的运行环境目录并回读结果;本页不会安装任何东西,也不授予任何权限。打开 Goal 对话时,如果选中的 +运行环境在本机不可用,输入框上方会给出同样的说明和重新检查。 +执行环境缺失不影响管家和 Goal 中的「仅查状态」:它保存原问题与状态回答,不启动 Agent。 +没有该选项的项目对话会明确拒绝未就绪的执行路线,并保留未发送的草稿。 + +LoopX drives Agents through a command-line tool installed on this machine (Codex, Claude Code, +Kiro CLI) or through an API key. When none is ready, the manager overview says so at the top, +lists what each runtime needs (a command not found on PATH, an environment variable not set), and +offers **Check again** and a **Setup guide** link. After installing and signing in, **Check again** +re-reads the service's runtime catalog and reads the result back; the page installs nothing and +grants nothing. In a Goal conversation, the same notice and re-check appear above the composer when +the selected runtime is unavailable here. +Missing execution runtimes do not disable **Status only** in manager and Goal conversations: it +stores the original question and status answer without starting an Agent. A project conversation +without that option refuses an unavailable execution route and retains the unsent draft. + ## 🧭 2. 控制台核心架构 ```mermaid diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 85701b5248..d5166c1588 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -8,6 +8,8 @@ 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 {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"; import {attentionNotificationsLiveScenario} from "./personal-workspace-browser/attention-notifications-live.mjs"; import {firstGoalHomeLiveScenario} from "./personal-workspace-browser/first-goal-home-live.mjs"; @@ -120,6 +122,8 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(runtimeReadinessScenario); +scenarioCatalog.push(runtimeReadinessLiveScenario); scenarioCatalog.push(attentionNotificationsScenario); scenarioCatalog.push(attentionNotificationsLiveScenario); scenarioCatalog.push(firstGoalHomeLiveScenario); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 059138f574..39a6c13715 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -448,6 +448,8 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu failNextStatusRequest: false, /** When set, every status read answers this HTTP status until cleared: a source that cannot be projected. */ statusFailure: null, + /** When set, replaces the capabilities adapters list: the runtimes this host reports. */ + runtimeAgentsOverride: null, freezeGoalSubagentStatusProjection: false, goalSubagentConfigurationEnabled, goalActivationStates: new Map([ @@ -1596,7 +1598,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu ...(state.goalSubagentConfigurationEnabled ? { goal_subagent_configuration: "preview_locked" } : {}), goal_id: null, streaming: true, resume: true, interrupt: true, typed_actions: true, action_kinds: ["goal.create", "goal.lifecycle", "agent.bind", "heartbeat.bind", "monitor.create", "run.correct"], - adapters: [ + adapters: state.runtimeAgentsOverride ?? [ { agent_id: "codex", display_name: "Codex", adapter_kind: "codex_app_server", available: true, streaming: true, resume: true, interrupt: true }, { agent_id: "claude-code", display_name: "Claude Code", adapter_kind: "claude_code_cli", available: true, streaming: true, resume: true, interrupt: true }, { agent_id: "offline-agent", display_name: "Offline Agent", adapter_kind: "acp", available: false, streaming: false, resume: false, interrupt: false }, diff --git a/examples/personal-workspace-browser/runtime-readiness-backend.py b/examples/personal-workspace-browser/runtime-readiness-backend.py new file mode 100644 index 0000000000..38bfdcde4f --- /dev/null +++ b/examples/personal-workspace-browser/runtime-readiness-backend.py @@ -0,0 +1,54 @@ +"""Real LoopX Chat service on a disposable runtime whose CLI runtimes are absent until the scenario installs them. + +The optional Goal is a disposable fixture; no model or credential exists here. +The service probes only the explicitly configured temporary executables. +""" +import json +import os +import runpy +import signal +import socket +import sys +import tempfile +from pathlib import Path + +root = Path(sys.argv[1]).resolve() +home = root / "home" +home.mkdir(parents=True, exist_ok=True) +os.environ["HOME"] = str(home) +tempfile.tempdir = str(root) +for variable in ("TMPDIR", "TEMP", "TMP"): + os.environ[variable] = str(root) +from loopx.chat_server import default_chat_assets_dir, serve_chat # noqa: E402 +from loopx.control_plane.effect_runtime import restart_effect_runtime # noqa: E402 + +bins = root / "bin" +bins.mkdir(parents=True, exist_ok=True) +(root / "project").mkdir(exist_ok=True) +for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): + os.environ.pop(variable, None) +os.environ["LOOPX_USAGE_PING"] = "0" +os.environ["DO_NOT_TRACK"] = "1" +if "--with-goal" in sys.argv[2:]: + fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "loopx-chat-actions-smoke.py")) + fixture["write_registry_fixture"](root) +with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] +print(json.dumps({"port": port, "bins": str(bins)}), flush=True) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +try: + serve_chat( + registry_path=root / "project" / ".loopx" / "registry.json", + runtime_root_override=str(root / "runtime"), + scan_roots=[root / "project"], + host="127.0.0.1", + port=port, + codex_bin=str(bins / "codex"), + claude_bin=str(bins / "claude"), + kiro_cli_bin=str(bins / "kiro-cli"), + assets_dir=default_chat_assets_dir(), + open_browser=False, + ) +finally: + restart_effect_runtime() diff --git a/examples/personal-workspace-browser/runtime-readiness-live.mjs b/examples/personal-workspace-browser/runtime-readiness-live.mjs new file mode 100644 index 0000000000..0470ee8fc5 --- /dev/null +++ b/examples/personal-workspace-browser/runtime-readiness-live.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { waitForHttp } from "../dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; + +// End to end against the real service: a disposable `loopx chat` whose Codex, +// Claude Code and Kiro executables do not exist. The packaged page reads the +// real catalog, names the real missing paths, and after the scenario installs +// a Codex executable, Check again re-reads the real probe and withdraws the +// notice. The Goal and exit-only executable are synthetic; catalog, messages, +// persistence and HTTP are the real local owners. No model is called. + +export const runtimeReadinessLiveScenario = { + id: "runtime-readiness-live", + async run({ browser, collectCoverage, url }) { + if (!packaged) return { coverageEntries: [], note: "live runtime readiness runs against the packaged bundle only" }; + const root = await mkdtemp(join(tmpdir(), "loopx-readiness-live-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/runtime-readiness-backend.py"), root, "--with-goal"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = ""; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + let session; + try { + const ready = await Promise.race([ + once(lines, "line").then(([line]) => JSON.parse(line)), + closed.then(() => { throw new Error(`readiness backend exited: ${diagnostics}`); }), + ]); + const backend = `http://127.0.0.1:${ready.port}`; + await waitForHttp(`${backend}/chat/`); + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + await page.route((target) => target.pathname === "/status.json" || target.pathname.startsWith("/api/chat/") || target.pathname.startsWith("/api/actions") || target.pathname.startsWith("/extension-"), async (route) => { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); }); + } }); + const { page } = session; + const notice = page.getByTestId("runtime-readiness"); + await notice.waitFor({ state: "visible", timeout: 20_000 }); + const text = await notice.innerText(); + assert.match(text, new RegExp(`Codex:未在 PATH 中找到命令「${ready.bins.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}/codex」`), `The real service names the probed path: ${text}`); + assert.match(text, /Claude API:未设置环境变量 ANTHROPIC_API_KEY/, "The real service names the unset variable"); + await page.screenshot({ path: resolve(outputDir, "runtime-readiness-live.png"), animations: "disabled" }); + + // On a phone the real probed paths must wrap inside the card: every + // paragraph, list item and code token ends within the notice and the + // viewport. The document's own scroll width cannot prove this. + await page.setViewportSize({ width: 390, height: 844 }); + await notice.waitFor({ state: "visible" }); + const bounds = await notice.evaluate((node) => { + const card = node.getBoundingClientRect(); + const children = [...node.querySelectorAll("p, li, code, h2")].map((child) => { const box = child.getBoundingClientRect(); return { tag: child.tagName, right: box.right, width: box.width }; }); + return { card: { right: card.right, width: card.width }, children, viewport: window.innerWidth }; + }); + for (const child of bounds.children) { + assert.ok(child.right <= bounds.card.right + 1 && child.right <= bounds.viewport, `${child.tag} ends at ${child.right} beyond the card (${bounds.card.right}) or the viewport (${bounds.viewport})`); + } + await page.screenshot({ path: resolve(outputDir, "runtime-readiness-live-phone.png"), animations: "disabled" }); + await page.setViewportSize({ width: 1440, height: 900 }); + + const turns = []; + const admissions = []; + page.on("request", (request) => { + if (request.method() !== "POST") return; + const path = new URL(request.url()).pathname; + if (/\/turns$/.test(path)) turns.push(request.url()); + if (path === "/api/chat/sessions") admissions.push(request.url()); + }); + const receipts = []; + const projectRefusals = []; + for (const locale of ["zh-CN", "en"]) { + const zh = locale === "zh-CN"; + await page.evaluate((value) => localStorage.setItem("loopx-pw-locale", value), locale); + await page.reload({ waitUntil: "networkidle" }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: zh ? "管家视图" : "Manager view" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + for (const scope of ["manager", "goal"]) { + if (scope === "goal") { + await page.locator(".personal-goal-link").first().click(); + await page.getByRole("navigation", { name: zh ? "Goal 视图" : "Goal view" }).getByRole("button", { name: zh ? "对话" : "Chat", exact: true }).click(); + } + await page.getByRole("combobox", { name: zh ? "选择聊天 Runtime" : "Select chat runtime", exact: true }).click(); + await page.getByRole("option", { name: zh ? "仅查状态" : "Status only", exact: true }).click(); + const question = zh ? `当前有哪些 Goal?${scope}` : `Which Goals are present? ${scope}`; + const saved = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/chat/projection-messages" && response.request().method() === "POST"); + await page.getByLabel(zh ? "向 LoopX 发送消息" : "Send a message to LoopX").fill(question); + await page.getByRole("button", { name: zh ? "发送" : "Send", exact: true }).click(); + const response = await saved; + assert.equal(response.status(), 201, "Status-only still persists the accepted question and answer"); + const receipt = await response.json(); + const stored = await (await fetch(`${backend}/api/chat/sessions/${receipt.session_id}`)).json(); + assert.equal(stored.session.adapter_kind, "status_projection"); + assert.ok(stored.messages.some((message) => message.message_id === receipt.user_message_id && message.text === question)); + const answer = stored.messages.find((message) => message.message_id === receipt.answer_message_id); + assert.ok(answer?.text); + await page.getByText(question, { exact: true }).waitFor({ state: "visible" }); + await page.setViewportSize({ width: 390, height: 844 }); + const line = page.getByTestId("runtime-readiness-line"); + const copy = await line.innerText(); + assert.doesNotMatch(copy, /无法发送消息|messages cannot be sent/, "Execution diagnosis must not deny the successful read-only task"); + assert.match(copy, zh ? /Agent 执行.*仅查状态/ : /Agent execution.*Status only/); + await page.screenshot({ path: resolve(outputDir, `runtime-readiness-${scope}-${locale}-phone.png`), animations: "disabled" }); + receipts.push({ scope, locale, session_id: receipt.session_id, user_message_id: receipt.user_message_id, answer_message_id: receipt.answer_message_id, status: response.status() }); + await page.setViewportSize({ width: 1440, height: 900 }); + } + // A project conversation has no status-only executor. Its diagnosis + // must not promise a readonly entry that this context does not offer. + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: zh ? "管家视图" : "Manager view" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + const projects = await (await fetch(`${backend}/api/chat/projects`)).json(); + assert.ok(projects.projects.length > 0); + await page.getByRole("combobox", { name: zh ? "范围" : "Scope", exact: true }).click(); + await page.getByRole("option", { name: projects.projects[0].title, exact: true }).click(); + const projectLine = page.getByTestId("runtime-readiness-line"); + await projectLine.waitFor({ state: "visible" }); + assert.match(await projectLine.innerText(), zh ? /Agent 执行尚未就绪/ : /Agent execution is not ready/); + assert.doesNotMatch(await projectLine.innerText(), /仅查状态|Status only/); + await page.getByRole("combobox", { name: zh ? "选择聊天 Runtime" : "Select chat runtime", exact: true }).click(); + assert.equal(await page.getByRole("option", { name: zh ? "仅查状态" : "Status only", exact: true }).count(), 0); + await page.keyboard.press("Escape"); + const admissionsBefore = admissions.length; + const draft = zh ? "保留这条未发送的项目请求" : "Retain this unsent project request"; + const input = page.getByLabel(zh ? "向 LoopX 发送消息" : "Send a message to LoopX"); + await input.fill(draft); + await page.getByRole("button", { name: zh ? "发送" : "Send", exact: true }).click(); + await page.locator(".personal-action-feedback").getByText(zh ? /发送失败.*需先修复运行环境/ : /Send failed.*repair the runtime first/).waitFor(); + assert.equal(await input.inputValue(), draft, "A refused execution keeps the owner's draft"); + assert.equal(admissions.length, admissionsBefore, "An unavailable route cannot request a Session"); + projectRefusals.push({ locale, draft_retained: true, session_admission_delta: 0 }); + } + assert.equal(turns.length, 0, "Status-only never starts a model Turn"); + await writeFile(resolve(outputDir, "runtime-readiness-status-only-receipts.json"), JSON.stringify({ real_local_service: true, receipts, project_refusals: projectRefusals, model_turns: turns.length }, null, 2)); + + // Return to the same home notice; a repaired execution CLI still clears it. + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + await page.reload({ waitUntil: "networkidle" }); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); + + // The owner installs Codex; Check again re-reads the real probe. + await writeFile(join(ready.bins, "codex"), "#!/bin/sh\nexit 0\n"); + await chmod(join(ready.bins, "codex"), 0o755); + await notice.getByRole("button", { name: "重新检查" }).click(); + await notice.waitFor({ state: "hidden", timeout: 20_000 }); + const catalog = await (await fetch(`${backend}/api/chat/capabilities`)).json(); + const codex = catalog.adapters.find((row) => row.agent_id === "codex"); + assert.equal(codex.available, true, "The real catalog now reports Codex available"); + assert.equal(codex.unavailable_reason, undefined); + return { coverageEntries: await session.close(), note: "real loopx chat service, live PATH probe, real re-check" }; + } catch (error) { + await session?.page.screenshot({ path: resolve(outputDir, "runtime-readiness-live-failure.png"), animations: "disabled" }).catch(() => {}); + if (session?.errors.length) process.stderr.write(`${JSON.stringify(session.errors)}\n`); + 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); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/runtime-readiness.mjs b/examples/personal-workspace-browser/runtime-readiness.mjs new file mode 100644 index 0000000000..8daf179455 --- /dev/null +++ b/examples/personal-workspace-browser/runtime-readiness.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// A machine with no Agent runtime must say so in place, name what each +// runtime needs, and let the owner re-check after installing, without the +// page installing or granting anything. The service's own readiness fields +// drive the copy; the home notice and the composer line share one rule. + +const missing = [ + { agent_id: "codex", display_name: "Codex", adapter_kind: "codex_app_server", available: false, streaming: true, resume: true, interrupt: true, executable: "codex", unavailable_reason: "executable_not_found" }, + { agent_id: "claude-code", display_name: "Claude Code", adapter_kind: "claude_code_cli", available: false, streaming: true, resume: true, interrupt: true, executable: "claude", unavailable_reason: "executable_not_found" }, + { agent_id: "anthropic-api", display_name: "Claude API", adapter_kind: "anthropic_messages_api", available: false, streaming: false, resume: true, interrupt: false, environment_variable: "ANTHROPIC_API_KEY", unavailable_reason: "api_key_not_set" }, +]; +const repaired = [{ ...missing[0], available: true, unavailable_reason: undefined }, missing[1], missing[2]]; + +export const runtimeReadinessScenario = { + id: "runtime-readiness", + async run({ browser, collectCoverage, url }) { + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (api) => { api.runtimeAgentsOverride = missing; } }); + const { api, page } = session; + try { + const notice = page.getByTestId("runtime-readiness"); + await notice.waitFor({ state: "visible", timeout: 15_000 }); + const text = await notice.innerText(); + assert.match(text, /还没有可用的 Agent 运行环境/); + assert.match(text, /Codex:未在 PATH 中找到命令「codex」/, "The notice names the missing command from the service's fields"); + assert.match(text, /Claude API:未设置环境变量 ANTHROPIC_API_KEY/); + assert.equal(await notice.getByRole("link", { name: "安装指引" }).count(), 1); + await page.screenshot({ path: resolve(outputDir, "runtime-readiness-none.png"), animations: "disabled" }); + + // Re-check after the owner installed Codex: the notice withdraws and nothing was sent. + api.runtimeAgentsOverride = repaired; + await notice.getByRole("button", { name: "重新检查" }).click(); + await notice.waitFor({ state: "hidden", timeout: 15_000 }); + assert.equal(await page.getByTestId("runtime-readiness").count(), 0, "A repaired runtime clears the notice"); + + // With a Goal open and execution runtimes missing, status-only remains usable. + api.runtimeAgentsOverride = missing; + await page.getByRole("button", { name: "刷新状态", exact: true }).click(); + await page.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + const line = page.getByTestId("runtime-readiness-line"); + await line.waitFor({ state: "visible", timeout: 15_000 }); + assert.match(await line.innerText(), /Agent 执行尚未就绪.*仅查状态/); + assert.doesNotMatch(await line.innerText(), /无法发送消息/); + await page.screenshot({ path: resolve(outputDir, "runtime-readiness-composer.png"), animations: "disabled" }); + api.runtimeAgentsOverride = repaired; + await line.getByRole("button", { name: "重新检查" }).click(); + await line.waitFor({ state: "hidden", timeout: 15_000 }); + assert.equal(api.turnRequests.length, 0, "Readiness never sends anything"); + return { coverageEntries: await session.close() }; + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +}; diff --git a/loopx/chat_endpoint_catalog.py b/loopx/chat_endpoint_catalog.py index 4b482ca298..4d0331f324 100644 --- a/loopx/chat_endpoint_catalog.py +++ b/loopx/chat_endpoint_catalog.py @@ -67,6 +67,24 @@ def managed_host_capability(runtime_root: Path | None = None) -> dict[str, Any]: } +def _executable_readiness(executable: str) -> dict[str, Any]: + """Live PATH probe for a CLI-backed endpoint, naming the missing command.""" + + if shutil.which(executable): + return {"available": True, "executable": executable} + return {"available": False, "executable": executable, + "unavailable_reason": "executable_not_found"} + + +def _environment_readiness(variable: str) -> dict[str, Any]: + """Presence probe for an API-key endpoint, naming the unset variable.""" + + if os.environ.get(variable, "").strip(): + return {"available": True, "environment_variable": variable} + return {"available": False, "environment_variable": variable, + "unavailable_reason": "api_key_not_set"} + + def builtin_chat_endpoints( *, codex_bin: str, @@ -74,14 +92,20 @@ def builtin_chat_endpoints( kiro_cli_bin: str, runtime_root: Path | None = None, ) -> list[dict[str, Any]]: - """Return the built-in endpoints, managed host included.""" + """Return the built-in endpoints, managed host included. + + An unavailable built-in row names what is missing so the owner can repair + it in place: ``unavailable_reason`` is ``executable_not_found`` with the + probed ``executable``, or ``api_key_not_set`` with the ``environment_variable``. + Availability itself stays a live probe; nothing is installed or granted. + """ return [ { "agent_id": "codex", "display_name": "Codex", "adapter_kind": "codex_app_server", - "available": bool(shutil.which(codex_bin)), + **_executable_readiness(codex_bin), "streaming": True, "resume": True, "interrupt": True, @@ -93,7 +117,7 @@ def builtin_chat_endpoints( "agent_id": "claude-code", "display_name": "Claude Code", "adapter_kind": "claude_code_cli", - "available": bool(shutil.which(claude_bin)), + **_executable_readiness(claude_bin), "streaming": True, "resume": True, "interrupt": True, @@ -111,7 +135,7 @@ def builtin_chat_endpoints( "agent_id": KIRO_CLI_CHAT_AGENT_ID, "display_name": KIRO_CLI_CHAT_DISPLAY_NAME, "adapter_kind": KIRO_CLI_CHAT_ADAPTER_KIND, - "available": bool(shutil.which(kiro_cli_bin)), + **_executable_readiness(kiro_cli_bin), "streaming": True, "resume": True, "interrupt": True, @@ -126,7 +150,7 @@ def builtin_chat_endpoints( "agent_id": "anthropic-api", "display_name": "Claude API", "adapter_kind": "anthropic_messages_api", - "available": bool(os.environ.get("ANTHROPIC_API_KEY", "").strip()), + **_environment_readiness("ANTHROPIC_API_KEY"), "streaming": False, "resume": True, "interrupt": False, @@ -138,7 +162,7 @@ def builtin_chat_endpoints( "agent_id": "openai-api", "display_name": "OpenAI API", "adapter_kind": "openai_messages_api", - "available": bool(os.environ.get("OPENAI_API_KEY", "").strip()), + **_environment_readiness("OPENAI_API_KEY"), "streaming": False, "resume": True, "interrupt": False, diff --git a/tests/test_chat_endpoint_readiness.py b/tests/test_chat_endpoint_readiness.py new file mode 100644 index 0000000000..5e24d4d50f --- /dev/null +++ b/tests/test_chat_endpoint_readiness.py @@ -0,0 +1,41 @@ +"""Built-in endpoints name what is missing so the owner can repair it in place.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from loopx.chat_endpoint_catalog import builtin_chat_endpoints + + +def _rows(monkeypatch, tmp_path: Path, **bins: str) -> dict[str, dict]: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "set") + rows = builtin_chat_endpoints( + codex_bin=bins.get("codex_bin", "definitely-missing-codex"), + claude_bin=bins.get("claude_bin", "definitely-missing-claude"), + kiro_cli_bin=bins.get("kiro_cli_bin", "definitely-missing-kiro"), + runtime_root=tmp_path, + ) + return {row["agent_id"]: row for row in rows} + + +def test_missing_executables_and_keys_name_their_requirement(monkeypatch, tmp_path: Path) -> None: + rows = _rows(monkeypatch, tmp_path) + for agent_id, executable in (("codex", "definitely-missing-codex"), ("claude-code", "definitely-missing-claude")): + assert rows[agent_id]["available"] is False + assert rows[agent_id]["unavailable_reason"] == "executable_not_found" + assert rows[agent_id]["executable"] == executable + assert rows["anthropic-api"]["available"] is False + assert rows["anthropic-api"]["unavailable_reason"] == "api_key_not_set" + assert rows["anthropic-api"]["environment_variable"] == "ANTHROPIC_API_KEY" + + +def test_present_requirements_report_available_without_a_reason(monkeypatch, tmp_path: Path) -> None: + rows = _rows(monkeypatch, tmp_path, codex_bin=sys.executable) + assert rows["codex"]["available"] is True + assert rows["codex"]["executable"] == sys.executable + assert "unavailable_reason" not in rows["codex"] + assert rows["openai-api"]["available"] is True + assert rows["openai-api"]["environment_variable"] == "OPENAI_API_KEY" + assert "unavailable_reason" not in rows["openai-api"]