From 930c465bbc516807e6256a73de17ec51648fc4ed Mon Sep 17 00:00:00 2001 From: Jalil Date: Fri, 13 Nov 2026 16:43:18 -0800 Subject: [PATCH 1/2] feat(desktop): simplify Diagnostics to a status line and Copy report Run diagnostics opened a section in Settings > Advanced that listed about twenty rows of internal state, waited for a Refresh click before checking anything, and copied only the Cloud tools state. Show one plain status line, the app and engine versions, and a Copy report button. The check runs once when a signed-in person opens the page. Copy report copies the same sanitized report as the command palette's "Copy diagnostics", with the result of the check included. The previous rows and the health JSON stay available under a closed "Technical details". The section is renamed Diagnostics in settings search and in the hints that point to it. Developer mode still opens the Debug page. Signed-off-by: Jalil --- .../connections/cloud-mcp-submit-readiness.ts | 8 +- .../domains/settings/advanced-sections.ts | 2 +- .../pages/advanced-diagnostics-section.tsx | 224 ++++++++++++++++++ .../settings/pages/advanced-view-sections.tsx | 162 +------------ .../domains/settings/pages/advanced-view.tsx | 13 +- .../settings/pages/diagnostics-summary.ts | 61 +++++ .../src/react-app/shell/settings-route.tsx | 20 ++ .../advanced-diagnostics-section.test.tsx | 208 ++++++++++++++++ .../diagnostics-copy-report.proof.test.ts | 79 ++++++ evals/worlds/account-diagnostics.ts | 10 +- 10 files changed, 619 insertions(+), 168 deletions(-) create mode 100644 apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx create mode 100644 apps/app/src/react-app/domains/settings/pages/diagnostics-summary.ts create mode 100644 apps/app/tests/advanced-diagnostics-section.test.tsx create mode 100644 evals/proofs/diagnostics-copy-report.proof.test.ts diff --git a/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts b/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts index a92f800d3a8..9508c383e80 100644 --- a/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts +++ b/apps/app/src/react-app/domains/connections/cloud-mcp-submit-readiness.ts @@ -265,7 +265,7 @@ export function assessCloudMcpSubmissionReadiness(input: { code: "provider_tool_projection_unverified", stage: "provider_projection", message: "OpenWork could not read tool capability information for the selected provider and model.", - recommendedAction: "Retry, or check Settings → Advanced → Agent access diagnostics if the problem continues.", + recommendedAction: "Retry, or check Settings → Advanced → Diagnostics if the problem continues.", }), }; } @@ -286,8 +286,8 @@ export function assessCloudMcpSubmissionReadiness(input: { ? "The selected model does not support tool calling." : "OpenWork could not confirm that the selected model supports tool calling.", recommendedAction: modelMissing - ? "Choose a model available from this provider, or check Settings → Advanced → Agent access diagnostics." - : "Choose a model with tool calling, or check Settings → Advanced → Agent access diagnostics.", + ? "Choose a model available from this provider, or check Settings → Advanced → Diagnostics." + : "Choose a model with tool calling, or check Settings → Advanced → Diagnostics.", }), }; } @@ -300,7 +300,7 @@ export function assessCloudMcpSubmissionReadiness(input: { stage: "provider_projection", retryable: false, message: "OpenWork received an unsupported tool capability result for the selected provider and model.", - recommendedAction: "Choose a model with tool calling, or check Settings → Advanced → Agent access diagnostics.", + recommendedAction: "Choose a model with tool calling, or check Settings → Advanced → Diagnostics.", }), }; } diff --git a/apps/app/src/react-app/domains/settings/advanced-sections.ts b/apps/app/src/react-app/domains/settings/advanced-sections.ts index 72acc2673e4..52817e5305f 100644 --- a/apps/app/src/react-app/domains/settings/advanced-sections.ts +++ b/apps/app/src/react-app/domains/settings/advanced-sections.ts @@ -1,7 +1,7 @@ export const ADVANCED_SETTINGS_SECTIONS = [ { id: "organization-server", title: "Organization server", keywords: ["den", "control plane", "server url", "reset server"] }, { id: "runtime", title: "Runtime", keywords: ["opencode engine", "openwork server", "connection status"] }, - { id: "agent-access", title: "Agent access diagnostics", keywords: ["cloud mcp", "health", "tools", "connections"] }, + { id: "agent-access", title: "Diagnostics", keywords: ["agent access", "cloud mcp", "health", "tools", "connections", "copy report", "support", "version"] }, { id: "config-sources", title: "OpenCode config sources", keywords: ["runtime db", "injected config", "project config", "global config"] }, { id: "experimental-engine", title: "Experimental engine", keywords: ["engine v2", "chat engine", "preview"] }, { id: "workspace-run-mode", title: "Workspace run mode", keywords: ["approvals", "permissions", "keep going", "feature flag"] }, diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx new file mode 100644 index 00000000000..b0e6433e4d6 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx @@ -0,0 +1,224 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { ChevronRight, Copy } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { OpenworkCloudMcpHealth } from "@/app/lib/openwork-server"; +import { sanitizeCloudMcpHealthDiagnostic } from "@/app/lib/diagnostic-sanitizer"; +import { RefreshButton, SettingsInset, SettingsNotice, SettingsStatusBadge } from "../settings-section"; +import { + LayoutSection, + LayoutSectionHeader, + LayoutSectionItem, + LayoutSectionTitle, +} from "../settings-layout"; +import { diagnosticsVersionLine, summarizeDiagnostics } from "./diagnostics-summary"; + +function DiagnosticRow(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} + +function joinList(values: string[]): string { + return values.length ? values.join(", ") : "none"; +} + +function formatMaybe(value: string | number | boolean | null | undefined): string { + if (value === null || value === undefined || value === "") return "unknown"; + return String(value); +} + +function formatMetadataRecord(value: Record | null | undefined): string { + if (!value || Object.keys(value).length === 0) return "none"; + return Object.entries(value).map(([key, nested]) => `${key}=${formatMaybe(nested)}`).join(", "); +} + +function formatSupportedFeatures(features: OpenworkCloudMcpHealth["compatibility"]["supportedFeatures"]): string { + return Object.entries(features).map(([key, enabled]) => `${key}:${enabled ? "yes" : "no"}`).join(", "); +} + +function formatPluginHashes(hashes: OpenworkCloudMcpHealth["compatibility"]["pluginFileHashes"]): string { + if (hashes.length === 0) return "none"; + return hashes.map((hash) => `${hash.name}=${hash.sha256 ? hash.sha256.slice(0, 12) : `unavailable${hash.error ? ` (${hash.error})` : ""}`}`).join(", "); +} + +function formatMcpToolExposure(input: { checked: boolean; includesMcpTools: boolean | null; present: string[]; missing: string[]; limitation?: string }): string { + if (!input.checked) return "not checked"; + const includes = input.includesMcpTools === null ? "unknown" : input.includesMcpTools ? "yes" : "no"; + return `includes MCP tools: ${includes}; present ${joinList(input.present)}; missing ${joinList(input.missing)}${input.limitation ? `; limitation: ${input.limitation}` : ""}`; +} + +function formatCheckedAt(checkedAt: string): string | null { + const date = new Date(checkedAt); + if (Number.isNaN(date.getTime())) return null; + return `checked ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`; +} + +/** The exact runtime state, for the support engineer reading over someone's shoulder. Closed by default. */ +function TechnicalDetails(props: { health: OpenworkCloudMcpHealth }) { + const health = props.health; + const projection = health.tools.providerProjection; + const compatibility = health.compatibility; + + return ( +
+ + + Technical details + +
+ + + + + + {health.engineInspection?.checked ? ( + `${server.name} ${server.status}${server.error ? ` (${server.error})` : ""}`).join("; ") + : "none tracked"} + /> + ) : null} + + + + {health.tools.direct.trace ? ( + `${step.step} ${step.ok ? "ok" : "failed"}${step.httpStatus !== undefined ? ` (HTTP ${step.httpStatus})` : ""} ${Math.max(0, Math.round(step.latencyMs))}ms`).join(" → ") || "no steps"}`} + /> + ) : null} + {health.firstFailure ? ( + + ) : null} + + + {compatibility ? ( + <> + + + + + + + + ) : null} + +
+ Show sanitized health JSON +
+            {JSON.stringify(sanitizeCloudMcpHealthDiagnostic(health), null, 2)}
+          
+
+
+
+ ); +} + +export interface AdvancedDiagnosticsSectionProps { + appVersion: string | null; + signedIn: boolean; + orgSelected: boolean; + cloudMcpHealth: OpenworkCloudMcpHealth | null; + onRefresh: () => Promise; + /** Copies the full sanitized report and tells the person it worked. */ + onCopyReport: () => Promise; +} + +export function AdvancedDiagnosticsSection(props: AdvancedDiagnosticsSectionProps) { + const [checking, setChecking] = useState(false); + const [copying, setCopying] = useState(false); + const [checkError, setCheckError] = useState(null); + const autoChecked = useRef(false); + + const check = async () => { + setChecking(true); + setCheckError(null); + try { + await props.onRefresh(); + } catch (error) { + setCheckError(error instanceof Error ? error.message : "Could not run the check."); + } finally { + setChecking(false); + } + }; + + const copy = async () => { + setCopying(true); + try { + await props.onCopyReport(); + } finally { + setCopying(false); + } + }; + + // "Run diagnostics" lands here: show a result instead of asking for another click. + useEffect(() => { + if (autoChecked.current || props.cloudMcpHealth || !props.signedIn || !props.orgSelected) return; + autoChecked.current = true; + void check(); + }, [props.cloudMcpHealth, props.signedIn, props.orgSelected]); + + const summary = summarizeDiagnostics({ + checking, + signedIn: props.signedIn, + orgSelected: props.orgSelected, + health: props.cloudMcpHealth, + }); + const facts = [ + diagnosticsVersionLine({ appVersion: props.appVersion, health: props.cloudMcpHealth }), + props.cloudMcpHealth ? formatCheckedAt(props.cloudMcpHealth.checkedAt) : null, + ].filter((fact): fact is string => Boolean(fact)); + + return ( + + + Diagnostics + + + + +
+ +
{summary.detail}
+ {facts.length > 0 ? ( +
+ {facts.join(" — ")} +
+ ) : null} +
+
+ + Check again + + +
+
+ + {checkError ? {checkError} : null} + {props.cloudMcpHealth ? : null} +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx index 4bde15876cb..034070558eb 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx @@ -8,8 +8,8 @@ import { Field, FieldLabel } from "@/components/ui/field"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import type { OpenworkServerClient, OpenworkCloudMcpHealth, OpenworkRuntimeConfigStatus } from "@/app/lib/openwork-server"; -import { sanitizeCloudMcpHealthDiagnostic, sanitizeDiagnosticRecord } from "@/app/lib/diagnostic-sanitizer"; +import type { OpenworkServerClient, OpenworkRuntimeConfigStatus } from "@/app/lib/openwork-server"; +import { sanitizeDiagnosticRecord } from "@/app/lib/diagnostic-sanitizer"; import { DEFAULT_DEN_API_BASE_URL, DEFAULT_DEN_BASE_URL, @@ -329,164 +329,6 @@ export function AdvancedRuntimeSection(props: AdvancedRuntimeSectionProps) { ); } -function DiagnosticRow(props: { label: string; value: string }) { - return ( -
-
{props.label}
-
{props.value}
-
- ); -} - -function joinList(values: string[]): string { - return values.length ? values.join(", ") : "none"; -} - -function formatMaybe(value: string | number | boolean | null | undefined): string { - if (value === null || value === undefined || value === "") return "unknown"; - return String(value); -} - -function formatMetadataRecord(value: Record | null | undefined): string { - if (!value || Object.keys(value).length === 0) return "none"; - return Object.entries(value).map(([key, nested]) => `${key}=${formatMaybe(nested)}`).join(", "); -} - -function formatSupportedFeatures(features: OpenworkCloudMcpHealth["compatibility"]["supportedFeatures"]): string { - return Object.entries(features).map(([key, enabled]) => `${key}:${enabled ? "yes" : "no"}`).join(", "); -} - -function formatPluginHashes(hashes: OpenworkCloudMcpHealth["compatibility"]["pluginFileHashes"]): string { - if (hashes.length === 0) return "none"; - return hashes.map((hash) => `${hash.name}=${hash.sha256 ? hash.sha256.slice(0, 12) : `unavailable${hash.error ? ` (${hash.error})` : ""}`}`).join(", "); -} - -function formatMcpToolExposure(input: { checked: boolean; includesMcpTools: boolean | null; present: string[]; missing: string[]; limitation?: string }): string { - if (!input.checked) return "not checked"; - const includes = input.includesMcpTools === null ? "unknown" : input.includesMcpTools ? "yes" : "no"; - return `includes MCP tools: ${includes}; present ${joinList(input.present)}; missing ${joinList(input.missing)}${input.limitation ? `; limitation: ${input.limitation}` : ""}`; -} - -interface AdvancedCloudMcpDiagnosticsSectionProps { - cloudMcpHealth: OpenworkCloudMcpHealth | null; - onRefresh: () => Promise; -} - -export function AdvancedCloudMcpDiagnosticsSection(props: AdvancedCloudMcpDiagnosticsSectionProps) { - const [busy, setBusy] = useState(false); - const [copyStatus, setCopyStatus] = useState(null); - const [refreshError, setRefreshError] = useState(null); - const safeHealth = sanitizeCloudMcpHealthDiagnostic(props.cloudMcpHealth); - const projection = props.cloudMcpHealth?.tools.providerProjection; - const compatibility = props.cloudMcpHealth?.compatibility; - - const refresh = async () => { - setBusy(true); - setCopyStatus(null); - setRefreshError(null); - try { - await props.onRefresh(); - } catch (error) { - setRefreshError(error instanceof Error ? error.message : "Could not refresh Cloud MCP diagnostics."); - } finally { - setBusy(false); - } - }; - - const copy = async () => { - const payload = JSON.stringify({ cloudMcpHealth: safeHealth }, null, 2); - await navigator.clipboard.writeText(payload); - setCopyStatus("Copied sanitized Cloud diagnostic."); - }; - - return ( - - - Agent access diagnostics - - Technical details for OpenWork Cloud MCP delivery. Tokens and Authorization headers are redacted before display or copy. - - - - - - OpenWork Cloud MCP health - - Use this when support needs exact runtime state. The main Connect card stays user-facing. - - - - - - - - {copyStatus ? {copyStatus} : null} - {refreshError ? {refreshError} : null} - {props.cloudMcpHealth ? ( -
-
- - - - - - {props.cloudMcpHealth.engineInspection?.checked ? ( - `${server.name} ${server.status}${server.error ? ` (${server.error})` : ""}`).join("; ") - : "none tracked"} - /> - ) : null} - - - - {props.cloudMcpHealth.tools.direct.trace ? ( - `${step.step} ${step.ok ? "ok" : "failed"}${step.httpStatus !== undefined ? ` (HTTP ${step.httpStatus})` : ""} ${Math.max(0, Math.round(step.latencyMs))}ms`).join(" → ") || "no steps"}`} - /> - ) : null} - {props.cloudMcpHealth.firstFailure ? ( - - ) : null} - - - {compatibility ? ( - <> - - - - - - - - ) : null} - -
-
- Show sanitized health JSON -
-                {JSON.stringify(safeHealth, null, 2)}
-              
-
-
- ) : ( - No Cloud MCP health has been loaded for this workspace yet. - )} -
-
- ); -} - interface AdvancedRuntimeConfigSourcesSectionProps { busy: boolean; canInspect: boolean; diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx index 7122a936c3d..ba24362ca82 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view.tsx @@ -7,16 +7,17 @@ import { ADVANCED_SETTINGS_SECTIONS } from "../advanced-sections"; import { Separator } from "@/components/ui/separator"; import type { OpencodeConnectStatus } from "@/app/types"; +import { readDenSettings } from "@/app/lib/den"; import type { OpenworkServerClient, OpenworkCloudMcpHealth, OpenworkRuntimeConfigStatus, OpenworkServerStatus } from "@/app/lib/openwork-server"; import { t } from "@/i18n"; import { LayoutStack } from "../settings-layout"; import type { useDenSession } from "../cloud/use-den-session"; +import { AdvancedDiagnosticsSection } from "./advanced-diagnostics-section"; import { advancedLocalReducer, initialAdvancedLocalState } from "./advanced-view-state"; import { AdvancedDeveloperSection, AdvancedEngineV2PreviewSection, - AdvancedCloudMcpDiagnosticsSection, AdvancedOrganizationServerSection, AdvancedRuntimeConfigSourcesSection, AdvancedRuntimeSection, @@ -53,6 +54,8 @@ export type AdvancedViewProps = { cloudMcpUrl: string | null; cloudMcpHealth: OpenworkCloudMcpHealth | null; refreshCloudMcpHealth: () => Promise; + appVersion: string | null; + copyDiagnosticsReport: () => Promise; engineClient: OpenworkServerClient | null; }; @@ -173,6 +176,8 @@ export function AdvancedView(props: AdvancedViewProps) { void refreshRuntimeConfigStatus(); }, [props.canInspectRuntimeConfig]); + const denSettings = readDenSettings(); + return (
@@ -199,9 +204,13 @@ export function AdvancedView(props: AdvancedViewProps) { openworkDetailLines={openworkDetailLines} /> - 0 ? parts.join(", ") : null; +} diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 893b8a08e53..57a0c238254 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -21,6 +21,7 @@ import { resolveOpenWorkExtensionCatalogPlatform, } from "@/app/constants"; import type { EnablementContext } from "@/app/enablement"; +import { buildDiagnosticsBundleJson } from "@/app/lib/diagnostics-bundle"; import { createClient, unwrap } from "@/app/lib/opencode"; import { createOpenworkServerClient, @@ -1310,6 +1311,23 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setCloudMcpHealthResult({ workspaceId, health }); return health; }, [currentCloudMcpModel, openworkClient, runtimeWorkspaceId, selectedWorkspaceEndpoint]); + // Same report as the command palette's "Copy diagnostics", plus the Cloud check shown on the Advanced page. + const copyDiagnosticsReport = useCallback(async () => { + try { + const report = await buildDiagnosticsBundleJson({ + clientConnected: Boolean(opencodeClient), + developerMode, + openworkServerStatus: openworkServerSnapshot.openworkServerStatus, + openworkServerUrl: openworkClient?.baseUrl ?? "", + runtimeWorkspaceId, + cloudMcpHealth, + }); + await navigator.clipboard.writeText(report); + toast.success(t("session.diagnostics_copied")); + } catch (error) { + toast.error(t("session.diagnostics_failed"), { description: describeRouteError(error) }); + } + }, [cloudMcpHealth, developerMode, opencodeClient, openworkClient, openworkServerSnapshot.openworkServerStatus, runtimeWorkspaceId]); const { commandPaletteOpen, setCommandPaletteOpen } = useCommandPaletteShortcut(!props.embedded); const developerModePaletteItem = useMemo( () => settingsDeveloperModePaletteItem(developerMode, () => { @@ -2641,6 +2659,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }} cloudMcpHealth={cloudMcpHealth} refreshCloudMcpHealth={refreshCloudMcpHealth} + appVersion={electronUpdaterState.appVersion} + copyDiagnosticsReport={copyDiagnosticsReport} organizationServer={denSession} engineClient={openworkClient} /> diff --git a/apps/app/tests/advanced-diagnostics-section.test.tsx b/apps/app/tests/advanced-diagnostics-section.test.tsx new file mode 100644 index 00000000000..18f2af39a7c --- /dev/null +++ b/apps/app/tests/advanced-diagnostics-section.test.tsx @@ -0,0 +1,208 @@ +/** @jsxImportSource react */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +import type { OpenworkCloudMcpHealth } from "../src/app/lib/openwork-server"; +import { + diagnosticsVersionLine, + summarizeDiagnostics, +} from "../src/react-app/domains/settings/pages/diagnostics-summary"; + +const ownedDom = !GlobalRegistrator.isRegistered && typeof document === "undefined"; +let createRoot: typeof import("react-dom/client")["createRoot"]; +let AdvancedDiagnosticsSection: typeof import("../src/react-app/domains/settings/pages/advanced-diagnostics-section")["AdvancedDiagnosticsSection"]; +let root: Root | null = null; +let host: HTMLDivElement; +let previousAct: unknown; + +function health(overrides: Partial = {}): OpenworkCloudMcpHealth { + return { + schemaVersion: 1, + phase: "ready", + usable: true, + usableByCurrentModel: true, + connectCatalogEnabled: true, + workspace: { id: "workspace-1", type: "local", directory: "/tmp/project", path: "/tmp/project" }, + desired: { + present: true, + name: "openwork-cloud", + revision: "revision-1", + config: { enabled: true }, + token: { present: true, metadata: {} }, + }, + delivery: { + state: "ready", + desiredRevision: "revision-1", + appliedRevision: "revision-1", + updatedAt: null, + appliedAt: null, + lastAttemptAt: null, + }, + engine: { status: "connected" }, + tools: { + expected: ["search_capabilities", "execute_capability"], + present: ["search_capabilities", "execute_capability"], + missing: [], + direct: { checked: true, source: "mcp_tools_list", expected: [], present: [], missing: [] }, + providerProjection: { checked: false, present: [], missing: [] }, + }, + pluginCanaries: { expected: [], present: [], missing: [] }, + compatibility: { + openwork: { serverVersion: "0.18.70", app: { version: "0.18.70" } }, + opencode: { expectedVersion: "2.2.0", actualVersion: "2.2.0", probe: "ok" }, + pluginFileHashes: [], + supportedFeatures: { + dynamicMcp: true, + directoryScoping: true, + toolIds: true, + providerToolProjection: true, + pluginCanaries: true, + }, + experimentalToolIds: { checked: false, expected: [], present: [], missing: [], includesMcpTools: null }, + experimentalProviderTools: { checked: false, expected: [], present: [], missing: [], includesMcpTools: null }, + }, + toolDenies: [], + firstFailure: null, + checkedAt: "2026-11-13T18:42:00.000Z", + ...overrides, + }; +} + +const expiredSignIn = health({ + usable: false, + firstFailure: { + code: "openwork_cloud_token_expired", + stage: "cloud_auth", + retryable: true, + recommendedAction: "Sign in again.", + message: "The Cloud token expired.", + }, +}); + +describe("diagnostics summary", () => { + const signedIn = { checking: false, signedIn: true, orgSelected: true }; + + test("working tools read as one reassuring line", () => { + expect(summarizeDiagnostics({ ...signedIn, health: health() })).toEqual({ + tone: "ready", + label: "Working", + detail: "Your connected tools are ready to use.", + }); + }); + + test("a failure names what went wrong in plain words, not the failure code", () => { + const summary = summarizeDiagnostics({ ...signedIn, health: expiredSignIn }); + expect(summary).toEqual({ + tone: "error", + label: "Needs attention", + detail: "Cloud authentication expired. Copy the report and send it to OpenWork support.", + }); + expect(summary.detail).not.toContain("openwork_cloud_token_expired"); + }); + + test("tools switched off are not reported as a problem", () => { + const off = health({ usable: false, desired: { ...health().desired, config: { enabled: false } } }); + expect(summarizeDiagnostics({ ...signedIn, health: off }).label).toBe("Turned off"); + }); + + test("without a result it says what is missing instead of guessing a status", () => { + expect(summarizeDiagnostics({ ...signedIn, health: null }).label).toBe("Not checked"); + expect(summarizeDiagnostics({ ...signedIn, signedIn: false, health: null }).label).toBe("Signed out"); + expect(summarizeDiagnostics({ ...signedIn, orgSelected: false, health: null }).label).toBe("No organization"); + expect(summarizeDiagnostics({ ...signedIn, checking: true, health: null }).label).toBe("Checking"); + }); + + test("checking again keeps the last result on screen", () => { + expect(summarizeDiagnostics({ ...signedIn, checking: true, health: expiredSignIn }).label).toBe("Needs attention"); + }); + + test("the version line leads with the app version and works before any check", () => { + expect(diagnosticsVersionLine({ appVersion: "0.18.70", health: null })).toBe("OpenWork v0.18.70"); + expect(diagnosticsVersionLine({ appVersion: null, health: health() })).toBe("OpenWork v0.18.70, OpenCode 2.2.0"); + expect(diagnosticsVersionLine({ appVersion: null, health: null })).toBeNull(); + }); +}); + +describe("Diagnostics section", () => { + beforeAll(async () => { + if (ownedDom) GlobalRegistrator.register({ url: "http://localhost/" }); + createRoot = (await import("react-dom/client")).createRoot; + AdvancedDiagnosticsSection = (await import("../src/react-app/domains/settings/pages/advanced-diagnostics-section")).AdvancedDiagnosticsSection; + }); + afterAll(async () => { if (ownedDom) await GlobalRegistrator.unregister(); }); + beforeEach(() => { + previousAct = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + host.remove(); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); + }); + + async function render(props: Partial[0]> = {}) { + const calls = { refresh: 0, copy: 0 }; + await act(async () => root?.render( + { calls.refresh += 1; return null; }} + onCopyReport={async () => { calls.copy += 1; }} + {...props} + />, + )); + return calls; + } + + function button(label: string) { + return [...host.querySelectorAll("button")] + .find((candidate) => candidate.textContent?.trim() === label); + } + + test("shows the status and app version up front and keeps the technical rows folded away", async () => { + await render(); + const summary = host.querySelector('[data-testid="diagnostics-summary"]')?.textContent ?? ""; + expect(summary).toContain("Working"); + expect(summary).toContain("OpenWork v0.18.70, OpenCode 2.2.0 — checked "); + const details = host.querySelector("details"); + expect(details?.querySelector("summary")?.textContent?.trim()).toBe("Technical details"); + expect(details?.open).toBe(false); + expect(details?.textContent).toContain("Desired revision"); + }); + + test("Copy report hands the whole report to the caller in one click", async () => { + const calls = await render(); + await act(async () => { button("Copy report")?.click(); }); + expect(calls.copy).toBe(1); + }); + + test("copying still works before anything has been checked", async () => { + const calls = await render({ cloudMcpHealth: null, signedIn: false }); + expect(host.textContent).toContain("Signed out"); + expect(host.textContent).toContain("OpenWork v0.18.70"); + expect(host.querySelector("details")).toBeNull(); + await act(async () => { button("Copy report")?.click(); }); + expect(calls).toEqual({ refresh: 0, copy: 1 }); + }); + + test("opening it signed in with no result runs the check once", async () => { + const first = await render({ cloudMcpHealth: null }); + expect(first.refresh).toBe(1); + const rerender = await render({ cloudMcpHealth: null }); + expect(rerender.refresh).toBe(0); + }); + + test("a failed check says so instead of leaving a stale status", async () => { + await render({ cloudMcpHealth: null, onRefresh: async () => { throw new Error("OpenWork server is not running."); } }); + expect(host.textContent).toContain("OpenWork server is not running."); + expect(host.textContent).toContain("Not checked"); + }); +}); diff --git a/evals/proofs/diagnostics-copy-report.proof.test.ts b/evals/proofs/diagnostics-copy-report.proof.test.ts new file mode 100644 index 00000000000..6569a6366c5 --- /dev/null +++ b/evals/proofs/diagnostics-copy-report.proof.test.ts @@ -0,0 +1,79 @@ +import { expect } from "vitest"; +import { spec } from "@openwork/testkit"; +import { accountDiagnostics } from "../worlds/account-diagnostics.ts"; + +const test = spec.world(accountDiagnostics, { + timeout: 300_000, + resources: { surfaces: ["appWeb"], services: ["den"] }, + needs: { commands: ["bun"] }, +}); + +const SECTION = "#advanced-agent-access"; + +test("a member whose connected tools stopped working sees what is wrong and copies one report to share", async ({ world, user, probe, step, evidence }) => { + /** The status lines, as the member reads them. */ + const status = async () => (await probe.dom('[data-testid="diagnostics-summary"]')).elements[0]?.text.replace(/\s+/g, " ").trim() ?? ""; + const technicalDetails = async () => ({ + all: (await probe.dom(`${SECTION} details`)).elements.length, + open: (await probe.dom(`${SECTION} details[open]`)).elements.length, + }); + await user.resizeViewport({ width: 1280, height: 900, deviceScaleFactor: 1 }); + + await step("given: the member's connection is off, so the account menu offers Run diagnostics", async () => { + await user.click({ testId: "account-status-menu" }); + await user.see({ role: "menuitem", label: "Run diagnostics" }, { timeoutMs: 30_000 }); + await user.screenshot(); + }); + + await step("after: Run diagnostics shows one status line, the versions and a Copy report button", async () => { + await user.click({ role: "menuitem", label: "Run diagnostics" }); + await user.see({ role: "button", label: "Copy report" }, { timeoutMs: 30_000 }); + // The check starts without another click and ends on a result, not a spinner. + const line = await probe.eventually(status, { + within: 60_000, label: "the check finishes without another click", + until: text => text.startsWith("Turned off") || text.startsWith("Needs attention"), + }); + const details = await technicalDetails(); + await user.notSee({ role: "button", label: "Copy sanitized diagnostic" }); + const short = details.all > 0 && details.open === 0; + evidence.recordAssertionEvidence( + "The state is one plain line and the technical rows stay closed", + `status reads: ${line}; technical details open: ${details.open} of ${details.all}`, + short, + ); + await user.screenshot(); + expect(short, "the technical rows are present but closed").toBe(true); + }); + + await step("after: Copy report puts the full report on the clipboard in one click", async () => { + await user.click({ role: "button", label: "Copy report" }); + await user.see({ text: "Diagnostics copied" }, { timeoutMs: 30_000 }); + const copied = await probe.eventually(() => world.readClipboard(), { + within: 15_000, label: "the report reached the clipboard", until: value => value.includes('"capturedAt"'), + }); + const report: unknown = JSON.parse(copied); + const has = (key: string) => typeof report === "object" && report !== null && Reflect.get(report, key) != null; + // Witness: the copied text is the whole report, including the check shown on screen. + const whole = has("capturedAt") && has("openworkServer") && has("cloudMcp") && has("developerLogs"); + evidence.recordAssertionEvidence( + "One click copies the whole report, with the result of the check in it", + `clipboard: ${copied.length} characters of JSON; has the capture time, server state, connected-tools check and recent logs: ${whole}`, + whole, + ); + await user.screenshot(); + expect(whole, "the copied report carries the connected-tools check and recent logs").toBe(true); + }); + + await step("the technical rows open with one click for whoever is helping", async () => { + await user.click({ text: "Technical details" }); + await user.see({ text: "Desired revision" }); + const details = await technicalDetails(); + evidence.recordAssertionEvidence( + "The exact runtime state is still one click away", + `technical details open: ${details.open} of ${details.all}; "Desired revision" row visible`, + details.open === 1, + ); + await user.screenshot(); + expect(details.open).toBe(1); + }); +}); diff --git a/evals/worlds/account-diagnostics.ts b/evals/worlds/account-diagnostics.ts index 8ccabf001d6..ab58b2dad74 100644 --- a/evals/worlds/account-diagnostics.ts +++ b/evals/worlds/account-diagnostics.ts @@ -15,5 +15,13 @@ export async function accountDiagnostics(seed: Seed) { const { app, workspace } = await signedInAppWeb(seed, { den, webPort, workspacePath, name: "account-diagnostics", engine: "v2", headless: true, }); - return { app, workspace, advancedUrl: `${app.webUrl}/settings/advanced/developer` }; + // Headless Chrome only lets the page write and read the clipboard once granted. + await app.client.send("Browser.grantPermissions", { + origin: new URL(app.webUrl).origin, permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + }); + return { + app, workspace, advancedUrl: `${app.webUrl}/settings/advanced/developer`, + // TODO(primitive): probe.clipboard should expose this read-only witness without browser evaluation. + readClipboard: () => seed.evalIn(app, () => navigator.clipboard.readText(), { awaitPromise: true }), + }; } From 48474654ff27e8a0603b0dbc749aae5d49bdc854 Mon Sep 17 00:00:00 2001 From: Jalil Date: Fri, 13 Nov 2026 17:01:51 -0800 Subject: [PATCH 2/2] fix(desktop): run the Diagnostics check once the workspace is connected Settings can open before the workspace has a server connection. The automatic check ran once at that moment, got no result, and never ran again, so the section stayed on "Not checked" until someone pressed Check again. Wait for the connection before the one automatic check, say so while waiting, and keep Check again disabled until it can run. The proof now accepts any finished result of the check. Signed-off-by: Jalil --- .../pages/advanced-diagnostics-section.tsx | 10 +++++++--- .../domains/settings/pages/advanced-view.tsx | 2 ++ .../domains/settings/pages/diagnostics-summary.ts | 10 +++++++++- apps/app/src/react-app/shell/settings-route.tsx | 1 + .../tests/advanced-diagnostics-section.test.tsx | 14 +++++++++++++- evals/proofs/diagnostics-copy-report.proof.test.ts | 4 ++-- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx index b0e6433e4d6..f5d5f0d0b5a 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-diagnostics-section.tsx @@ -128,6 +128,8 @@ export interface AdvancedDiagnosticsSectionProps { signedIn: boolean; orgSelected: boolean; cloudMcpHealth: OpenworkCloudMcpHealth | null; + /** False until the workspace has a server connection the check can ask. */ + canCheck: boolean; onRefresh: () => Promise; /** Copies the full sanitized report and tells the person it worked. */ onCopyReport: () => Promise; @@ -161,14 +163,16 @@ export function AdvancedDiagnosticsSection(props: AdvancedDiagnosticsSectionProp }; // "Run diagnostics" lands here: show a result instead of asking for another click. + // The page can open before the workspace connection exists, so wait for it. useEffect(() => { - if (autoChecked.current || props.cloudMcpHealth || !props.signedIn || !props.orgSelected) return; + if (autoChecked.current || props.cloudMcpHealth || !props.canCheck || !props.signedIn || !props.orgSelected) return; autoChecked.current = true; void check(); - }, [props.cloudMcpHealth, props.signedIn, props.orgSelected]); + }, [props.cloudMcpHealth, props.canCheck, props.signedIn, props.orgSelected]); const summary = summarizeDiagnostics({ checking, + canCheck: props.canCheck, signedIn: props.signedIn, orgSelected: props.orgSelected, health: props.cloudMcpHealth, @@ -200,7 +204,7 @@ export function AdvancedDiagnosticsSection(props: AdvancedDiagnosticsSectionProp ) : null}
- + Check again