Make provider diagnostics easier to share (#1611)

* Show PATH matches in provider diagnostics

* Add copyable provider command probes

* Fix provider diagnostic path reporting

* Clean up command probe diagnostics
This commit is contained in:
Mohamed Boudra
2026-06-19 12:46:39 +08:00
committed by GitHub
parent f9660c7e89
commit cda66ae5f3
14 changed files with 453 additions and 21 deletions

View File

@@ -1,4 +1,5 @@
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -248,6 +250,7 @@ function DiagnosticSubSheet({
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -288,31 +291,62 @@ function DiagnosticSubSheet({
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
}, [diagnostic, t, toast]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<View style={sheetStyles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
@@ -733,6 +767,11 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},

View File

@@ -1778,6 +1778,9 @@ export const ar: TranslationResources = {
button: "التشخيص",
refresh: "ينعش",
refreshing: "منعش...",
copyLabel: "التشخيص",
copyAccessibility: "نسخ التشخيص",
copyFailed: "فشل نسخ التشخيص",
refreshAccessibility: "تحديث التشخيص",
refreshingAccessibility: "تحديث التشخيص",
running: "تشغيل التشخيص...",

View File

@@ -1786,6 +1786,9 @@ export const en = {
button: "Diagnostic",
refresh: "Refresh",
refreshing: "Refreshing...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
running: "Running diagnostic...",

View File

@@ -1819,6 +1819,9 @@ export const es: TranslationResources = {
button: "Diagnóstico",
refresh: "Refrescar",
refreshing: "Refrescante...",
copyLabel: "diagnóstico",
copyAccessibility: "Copiar diagnóstico",
copyFailed: "No se pudo copiar el diagnóstico",
refreshAccessibility: "Actualizar diagnóstico",
refreshingAccessibility: "Diagnóstico refrescante",
running: "Ejecutando diagnóstico...",

View File

@@ -1824,6 +1824,9 @@ export const fr: TranslationResources = {
button: "Diagnostique",
refresh: "Rafraîchir",
refreshing: "Rafraîchissant...",
copyLabel: "diagnostic",
copyAccessibility: "Copier le diagnostic",
copyFailed: "Échec de la copie du diagnostic",
refreshAccessibility: "Actualiser le diagnostic",
refreshingAccessibility: "Diagnostic rafraîchissant",
running: "Exécution du diagnostic...",

View File

@@ -1810,6 +1810,9 @@ export const ru: TranslationResources = {
button: "Диагностика",
refresh: "Обновить",
refreshing: "Освежающий...",
copyLabel: "диагностика",
copyAccessibility: "Скопировать диагностику",
copyFailed: "Не удалось скопировать диагностику",
refreshAccessibility: "Обновить диагностику",
refreshingAccessibility: "Обновление диагностики",
running: "Запускаю диагностику...",

View File

@@ -1755,6 +1755,9 @@ export const zhCN: TranslationResources = {
button: "诊断",
refresh: "刷新",
refreshing: "正在刷新...",
copyLabel: "诊断",
copyAccessibility: "复制诊断",
copyFailed: "复制诊断失败",
refreshAccessibility: "刷新诊断",
refreshingAccessibility: "正在刷新诊断",
running: "正在运行诊断...",

View File

@@ -35,6 +35,7 @@ import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -1494,6 +1495,9 @@ export class ClaudeAgentClient implements AgentClient {
return {
diagnostic: formatProviderDiagnostic("Claude Code", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["claude"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
...(auth ? [{ label: "Auth", value: auth }] : []),
{ label: "Models", value: modelsValue },

View File

@@ -87,6 +87,7 @@ import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
@@ -5606,6 +5607,9 @@ export class CodexAppServerAgentClient implements AgentClient {
const availability = await checkCodexLaunchAvailable(launch);
const available = availability.available;
const entries: Array<{ label: string; value: string }> = [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["codex"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
];
let status = formatDiagnosticStatus(available);

View File

@@ -20,6 +20,7 @@ import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
@@ -127,6 +128,9 @@ export class CopilotACPAgentClient extends ACPAgentClient {
return {
diagnostic: formatProviderDiagnostic("Copilot", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["copilot"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },

View File

@@ -1,6 +1,131 @@
import { describe, expect, test } from "vitest";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
import {
buildCommandResolutionDiagnosticRows,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
function makeTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-diagnostic-path-"));
tempDirs.push(dir);
return dir;
}
function writeExecutable(dir: string, name: string): string {
const filePath = path.join(dir, name);
writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
describe("buildCommandResolutionDiagnosticRows", () => {
test("reports daemon PATH matches for known binary names", async () => {
const binDir = makeTempDir();
const binaryPath = writeExecutable(binDir, "claude");
const rows = await buildCommandResolutionDiagnosticRows(
{ command: "claude", args: [], source: "default" },
{
knownBinaryNames: ["claude"],
pathValue: binDir,
},
);
expect(rows).toContainEqual({ label: "Command source", value: "default" });
expect(rows).toContainEqual({ label: "Configured command", value: "claude" });
expect(rows).toContainEqual({
label: "Daemon PATH",
value: expect.stringContaining(binDir),
});
expect(rows).toContainEqual({
label: "PATH matches",
value: expect.stringContaining(binaryPath),
});
if (process.platform === "win32") {
expect(rows.some((row) => row.label === "where.exe claude")).toBe(true);
expect(rows.some((row) => row.label === "powershell Get-Command -All claude")).toBe(true);
} else {
expect(rows.some((row) => row.label === "which -a claude")).toBe(true);
expect(rows.some((row) => row.label.endsWith(" -lc type -a claude"))).toBe(true);
}
});
test("reports none when the daemon PATH has no matching executable", async () => {
const binDir = makeTempDir();
const rows = await buildCommandResolutionDiagnosticRows(
{ command: "claude", args: [], source: "default" },
{ knownBinaryNames: ["claude"], includeCommandProbes: false, pathValue: binDir },
);
expect(rows).toContainEqual({ label: "PATH matches", value: "none" });
});
test("truncates very long daemon PATH values", async () => {
const rows = await buildCommandResolutionDiagnosticRows(
{ command: "claude", args: [], source: "default" },
{
knownBinaryNames: ["claude"],
includeCommandProbes: false,
pathValue: "x".repeat(5000),
},
);
expect(rows).toContainEqual({
label: "Daemon PATH",
value: expect.stringContaining("(truncated)"),
});
});
test("matches Windows PATHEXT executable names", async () => {
const binDir = makeTempDir();
const binaryPath = writeExecutable(binDir, "claude.EXE");
const rows = await buildCommandResolutionDiagnosticRows(
{ command: "claude", args: [], source: "default" },
{
knownBinaryNames: ["claude"],
includeCommandProbes: false,
pathext: ".EXE;.CMD",
pathValue: binDir,
platform: "win32",
},
);
expect(rows).toContainEqual({
label: "PATH matches",
value: expect.stringContaining(binaryPath),
});
});
test("does not treat absolute configured commands as PATH binary names", async () => {
const rows = await buildCommandResolutionDiagnosticRows(
{ command: "/Users/mn/.local/bin/claude", args: [], source: "override" },
{
knownBinaryNames: ["/Users/mn/.local/bin/claude"],
includeCommandProbes: false,
},
);
expect(rows).toContainEqual({ label: "PATH matches", value: "not checked" });
});
});
describe("toDiagnosticErrorMessage", () => {
test("returns message for plain Error", () => {

View File

@@ -1,3 +1,7 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import path from "node:path";
import {
createProviderEnvSpec,
type ProviderLaunchAvailability,
@@ -154,6 +158,232 @@ export interface BinaryDiagnosticRowsOptions {
versionCommand?: BinaryDiagnosticVersionCommand;
}
export interface CommandResolutionDiagnosticRowsOptions {
knownBinaryNames: readonly string[];
includeCommandProbes?: boolean;
pathValue?: string;
pathext?: string;
platform?: NodeJS.Platform;
shell?: string;
}
const COMMAND_PROBE_TIMEOUT_MS = 3_000;
const COMMAND_PROBE_MAX_BUFFER = 32 * 1024;
function resolvePlatform(options?: CommandResolutionDiagnosticRowsOptions): NodeJS.Platform {
return options?.platform ?? process.platform;
}
function resolvePathValue(options?: CommandResolutionDiagnosticRowsOptions): string {
return options?.pathValue ?? process.env["PATH"] ?? process.env["Path"] ?? "";
}
function resolveShellValue(options?: CommandResolutionDiagnosticRowsOptions): string {
if (options?.shell) {
return options.shell;
}
if (resolvePlatform(options) === "win32") {
return process.env["ComSpec"] ?? "cmd.exe";
}
return process.env["SHELL"] ?? "/bin/sh";
}
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
try {
const candidate = await stat(filePath);
if (!candidate.isFile()) {
return false;
}
if (platform === "win32") {
return true;
}
await access(filePath, constants.X_OK);
return true;
} catch {
return false;
}
}
function resolveSearchableNames(binaryNames: readonly string[]): string[] {
return binaryNames.filter(
(binaryName) =>
binaryName.trim().length > 0 && !binaryName.includes("/") && !binaryName.includes("\\"),
);
}
function resolveWindowsPathExt(options: CommandResolutionDiagnosticRowsOptions): string[] {
const value = options.pathext ?? process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD";
return value
.split(";")
.map((extension) => extension.trim())
.filter(Boolean)
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
}
function resolveBinaryCandidateNames(
binaryName: string,
options: CommandResolutionDiagnosticRowsOptions,
): string[] {
if (resolvePlatform(options) !== "win32" || path.win32.extname(binaryName)) {
return [binaryName];
}
return [binaryName, ...resolveWindowsPathExt(options).map((extension) => binaryName + extension)];
}
async function formatPathMatches(options: CommandResolutionDiagnosticRowsOptions): Promise<string> {
const binaryNames = options.knownBinaryNames;
const searchableNames = resolveSearchableNames(binaryNames);
if (searchableNames.length === 0) {
return "not checked";
}
const pathDelimiter = resolvePlatform(options) === "win32" ? ";" : path.delimiter;
const pathEntries = resolvePathValue(options).split(pathDelimiter).filter(Boolean);
const matches: string[] = [];
const seen = new Set<string>();
const platform = resolvePlatform(options);
for (const directory of pathEntries) {
for (const binaryName of searchableNames) {
for (const candidateName of resolveBinaryCandidateNames(binaryName, options)) {
const candidate = path.join(directory, candidateName);
if (seen.has(candidate)) {
continue;
}
seen.add(candidate);
if (await isExecutableFile(candidate, platform)) {
matches.push(candidate);
}
}
}
}
return matches.length > 0 ? matches.join("\n ") : "none";
}
function shellToken(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
function formatCommandProbeOutput(stdout: string, stderr: string): string {
const sections: string[] = [];
const trimmedStdout = truncateForDiagnostic(stdout);
const trimmedStderr = truncateForDiagnostic(stderr);
if (trimmedStdout.length > 0) {
sections.push(trimmedStdout);
}
if (trimmedStderr.length > 0) {
sections.push(`stderr: ${trimmedStderr}`);
}
return sections.length > 0 ? sections.join("\n") : "(no output)";
}
function formatCommandProbeError(error: unknown): string {
return toDiagnosticErrorMessage(error);
}
async function runCommandProbe(command: string, args: string[]): Promise<string> {
try {
const { stdout, stderr } = await execCommand(command, args, {
timeout: COMMAND_PROBE_TIMEOUT_MS,
killSignal: "SIGKILL",
maxBuffer: COMMAND_PROBE_MAX_BUFFER,
});
return formatCommandProbeOutput(stdout, stderr);
} catch (error) {
return formatCommandProbeError(error);
}
}
async function buildPosixCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
const shell = resolveShellValue();
const typeCommand = `type -a ${shellToken(binaryName)}`;
return [
{
label: `which -a ${binaryName}`,
value: await runCommandProbe("/usr/bin/which", ["-a", binaryName]),
},
{
label: `${path.basename(shell)} -lc type -a ${binaryName}`,
value: await runCommandProbe(shell, ["-lc", typeCommand]),
},
];
}
async function buildWindowsCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
const powershellCommand = [
"$ErrorActionPreference = 'Continue';",
`Get-Command -All ${JSON.stringify(binaryName)} |`,
"Select-Object CommandType,Source,Name,Definition |",
"Format-List",
].join(" ");
return [
{
label: `where.exe ${binaryName}`,
value: await runCommandProbe("where.exe", [binaryName]),
},
{
label: `powershell Get-Command -All ${binaryName}`,
value: await runCommandProbe("powershell.exe", [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
powershellCommand,
]),
},
];
}
async function buildCommandProbeRows(binaryNames: readonly string[]): Promise<DiagnosticEntry[]> {
const searchableNames = resolveSearchableNames(binaryNames);
if (searchableNames.length === 0) {
return [];
}
const rows: DiagnosticEntry[] = [];
for (const binaryName of searchableNames) {
rows.push(
...(process.platform === "win32"
? await buildWindowsCommandProbeRows(binaryName)
: await buildPosixCommandProbeRows(binaryName)),
);
}
return rows;
}
export async function buildCommandResolutionDiagnosticRows(
launch: ResolvedProviderLaunch,
options: CommandResolutionDiagnosticRowsOptions,
): Promise<DiagnosticEntry[]> {
const includeCommandProbes = options.includeCommandProbes ?? true;
return [
{
label: "Command source",
value: launch.source,
},
{
label: "Configured command",
value: [launch.command, ...launch.args].join(" "),
},
{
label: "Daemon PATH",
value: truncateForDiagnostic(resolvePathValue(options)) || "(empty)",
},
{
label: "Daemon shell",
value: resolveShellValue(options),
},
{
label: "PATH matches",
value: await formatPathMatches(options),
},
...(includeCommandProbes ? await buildCommandProbeRows(options.knownBinaryNames) : []),
];
}
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
try {
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {

View File

@@ -71,6 +71,7 @@ import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
@@ -1609,6 +1610,9 @@ export class OpenCodeAgentClient implements AgentClient {
return {
diagnostic: formatProviderDiagnostic("OpenCode", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["opencode"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Server", value: serverStatus },
{ label: "Auth", value: authValue },

View File

@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { composeSystemPromptParts } from "../../system-prompt.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -2062,6 +2063,9 @@ export class PiRpcAgentClient implements AgentClient {
return {
diagnostic: formatProviderDiagnostic("Pi", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: [launch.command],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Configured providers", value: configuredProvidersValue },
{