Add capability diagnostics

This commit is contained in:
murashit 2026-05-17 15:24:14 +09:00
parent 1bcaa55f82
commit 56dba17bde
14 changed files with 655 additions and 67 deletions

View file

@ -7,7 +7,11 @@ import type { ConfigWriteResponse } from "../generated/app-server/v2/ConfigWrite
import type { GetAccountRateLimitsResponse } from "../generated/app-server/v2/GetAccountRateLimitsResponse";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { HooksListResponse } from "../generated/app-server/v2/HooksListResponse";
import type { CollaborationModeListResponse } from "../generated/app-server/v2/CollaborationModeListResponse";
import type { ListMcpServerStatusParams } from "../generated/app-server/v2/ListMcpServerStatusParams";
import type { ListMcpServerStatusResponse } from "../generated/app-server/v2/ListMcpServerStatusResponse";
import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse";
import type { ModelProviderCapabilitiesReadResponse } from "../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
import type { SkillsListResponse } from "../generated/app-server/v2/SkillsListResponse";
import type { ThreadArchiveResponse } from "../generated/app-server/v2/ThreadArchiveResponse";
import type { ThreadForkResponse } from "../generated/app-server/v2/ThreadForkResponse";
@ -25,7 +29,7 @@ import type { TurnSteerResponse } from "../generated/app-server/v2/TurnSteerResp
import type { UserInput } from "../generated/app-server/v2/UserInput";
import { CLIENT_VERSION } from "../constants";
import { StdioAppServerTransport, type AppServerTransport, type AppServerTransportHandlers } from "./transport";
import type { ClientRequestMethod, ClientRequestParams, PendingRequest, RpcInboundMessage, RpcOutboundMessage } from "./types";
import type { ClientRequestMethod, ClientRequestParams, PendingRequest, RpcError, RpcInboundMessage, RpcOutboundMessage } from "./types";
import type { ServerNotification } from "../generated/app-server/ServerNotification";
import type { ServerRequest } from "../generated/app-server/ServerRequest";
import type { JsonValue } from "../generated/app-server/serde_json/JsonValue";
@ -42,6 +46,20 @@ export interface AppServerClientHandlers {
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
export class AppServerRpcError extends Error {
readonly code?: number;
readonly data?: unknown;
readonly method: ClientRequestMethod;
constructor(method: ClientRequestMethod, error: RpcError) {
super(error.message || "Codex app-server request failed.");
this.name = "AppServerRpcError";
this.code = error.code;
this.data = error.data;
this.method = method;
}
}
interface ClientResponseByMethod {
initialize: InitializeResponse;
"config/batchWrite": ConfigWriteResponse;
@ -59,6 +77,9 @@ interface ClientResponseByMethod {
"skills/list": SkillsListResponse;
"model/list": ModelListResponse;
"account/rateLimits/read": GetAccountRateLimitsResponse;
"mcpServerStatus/list": ListMcpServerStatusResponse;
"collaborationMode/list": CollaborationModeListResponse;
"modelProvider/capabilities/read": ModelProviderCapabilitiesReadResponse;
"thread/compact/start": Record<string, never>;
"turn/start": TurnStartResponse;
"turn/steer": TurnSteerResponse;
@ -266,6 +287,20 @@ export class AppServerClient {
return this.request("account/rateLimits/read", undefined);
}
listMcpServerStatus(
params: ListMcpServerStatusParams = { detail: "toolsAndAuthOnly", limit: 100 },
): Promise<ListMcpServerStatusResponse> {
return this.request("mcpServerStatus/list", params);
}
listCollaborationModes(): Promise<CollaborationModeListResponse> {
return this.request("collaborationMode/list", {});
}
readModelProviderCapabilities(): Promise<ModelProviderCapabilitiesReadResponse> {
return this.request("modelProvider/capabilities/read", {});
}
compactThread(threadId: string): Promise<Record<string, never>> {
return this.request("thread/compact/start", { threadId });
}
@ -416,7 +451,7 @@ export class AppServerClient {
window.clearTimeout(pending.timeout);
this.pending.delete(message.id);
if ("error" in message && message.error) {
pending.reject(new Error(message.error.message || "Codex app-server request failed."));
pending.reject(new AppServerRpcError(pending.method, message.error));
} else {
pending.resolve(message.result);
}

View file

@ -1,16 +1,82 @@
import type { InitializeResponse } from "../generated/app-server/InitializeResponse";
import type { McpAuthStatus } from "../generated/app-server/v2/McpAuthStatus";
import type { McpServerStartupState } from "../generated/app-server/v2/McpServerStartupState";
export type CapabilityProbeState = "unknown" | "ok" | "failed";
export const CAPABILITY_PROBE_METHODS = [
"model/list",
"skills/list",
"hooks/list",
"account/rateLimits/read",
"mcpServerStatus/list",
"collaborationMode/list",
"modelProvider/capabilities/read",
] as const;
export interface AppServerCompatibility {
modelList: CapabilityProbeState;
modelListError: string | null;
export type CapabilityProbeMethod = (typeof CAPABILITY_PROBE_METHODS)[number];
export type CapabilityProbeStatus = "unknown" | "ok" | "failed" | "unsupported";
export interface CapabilityProbeResult {
method: CapabilityProbeMethod;
status: CapabilityProbeStatus;
message: string | null;
summary: string | null;
checkedAt: number | null;
}
export function createAppServerCompatibility(): AppServerCompatibility {
export interface McpServerDiagnostic {
name: string;
startupStatus: McpServerStartupState | "unknown";
authStatus: McpAuthStatus | null;
toolCount: number | null;
message: string | null;
}
export interface AppServerDiagnostics {
probes: Record<CapabilityProbeMethod, CapabilityProbeResult>;
mcpServers: McpServerDiagnostic[];
}
export function createAppServerDiagnostics(): AppServerDiagnostics {
return {
modelList: "unknown",
modelListError: null,
probes: Object.fromEntries(CAPABILITY_PROBE_METHODS.map((method) => [method, createCapabilityProbeResult(method)])) as Record<
CapabilityProbeMethod,
CapabilityProbeResult
>,
mcpServers: [],
};
}
export function createCapabilityProbeResult(method: CapabilityProbeMethod): CapabilityProbeResult {
return {
method,
status: "unknown",
message: null,
summary: null,
checkedAt: null,
};
}
export function capabilityProbeOk(
method: CapabilityProbeMethod,
summary: string | null = null,
checkedAt = Date.now(),
): CapabilityProbeResult {
return {
method,
status: "ok",
message: null,
summary,
checkedAt,
};
}
export function capabilityProbeError(method: CapabilityProbeMethod, error: unknown, checkedAt = Date.now()): CapabilityProbeResult {
return {
method,
status: isUnsupportedRpcError(error) ? "unsupported" : "failed",
message: shortErrorMessage(error),
summary: null,
checkedAt,
};
}
@ -25,7 +91,36 @@ export function appServerPlatform(initializeResponse: InitializeResponse | null)
return `${os}/${family}`;
}
export function compatibilitySummary(compatibility: AppServerCompatibility): string {
const modelList = compatibility.modelList === "failed" ? `model/list failed` : `model/list ${compatibility.modelList}`;
return `${modelList}; Plan mode uses experimental collaborationMode override`;
export function upsertMcpServerDiagnostic(diagnostics: AppServerDiagnostics, server: McpServerDiagnostic): AppServerDiagnostics {
const current = diagnostics.mcpServers.find((item) => item.name === server.name);
const merged = mergeMcpServerDiagnostic(current, server);
const existing = diagnostics.mcpServers.filter((item) => item.name !== server.name);
return {
...diagnostics,
mcpServers: [...existing, merged].sort((a, b) => a.name.localeCompare(b.name)),
};
}
export function shortErrorMessage(error: unknown, maxLength = 160): string {
const message = error instanceof Error ? error.message : String(error);
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
}
function isUnsupportedRpcError(error: unknown): boolean {
const maybeCode = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined;
if (maybeCode === -32601) return true;
const message = error instanceof Error ? error.message : String(error);
return /\bmethod not found\b/i.test(message) || /\b(unsupported|unknown)\s+(rpc\s+)?method\b/i.test(message);
}
function mergeMcpServerDiagnostic(current: McpServerDiagnostic | undefined, update: McpServerDiagnostic): McpServerDiagnostic {
const startupUpdated = update.startupStatus !== "unknown";
return {
name: update.name,
startupStatus: startupUpdated ? update.startupStatus : (current?.startupStatus ?? "unknown"),
authStatus: update.authStatus ?? current?.authStatus ?? null,
toolCount: update.toolCount ?? current?.toolCount ?? null,
message: update.message ?? (startupUpdated ? null : (current?.message ?? null)),
};
}

View file

@ -35,6 +35,7 @@ export interface PanelControllerActions {
refreshThreads: () => void;
refreshSkills: (forceReload?: boolean) => void;
maybeNameThread: (threadId: string, turn: Turn) => void;
recordMcpStartupStatus: (name: string, status: "starting" | "ready" | "failed" | "cancelled", message: string | null) => void;
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
@ -352,14 +353,7 @@ export class PanelController {
private handleMcpStartupStatus(params: Extract<ServerNotification, { method: "mcpServer/startupStatus/updated" }>["params"]): void {
if (!params?.name) return;
if (params.status === "failed") {
const key = `${params.name}:${params.error ?? ""}`;
if (this.state.reportedMcpFailures.has(key)) return;
this.state.reportedMcpFailures.add(key);
this.addSystemMessage(
`MCP server failed to start: ${params.name}\n${params.error ?? ""}\n\nThis is non-fatal for the Codex thread unless that MCP server is needed.`,
);
}
this.actions.recordMcpStartupStatus(params.name, params.status, params.error ?? null);
}
}

View file

@ -1,7 +1,7 @@
import { appServerIdentity, appServerPlatform, compatibilitySummary } from "../app-server/compatibility";
import { CAPABILITY_PROBE_METHODS, appServerIdentity, appServerPlatform } from "../app-server/compatibility";
import { CLIENT_VERSION } from "../constants";
import type { InitializeResponse } from "../generated/app-server/InitializeResponse";
import type { AppServerCompatibility } from "../app-server/compatibility";
import type { AppServerDiagnostics, CapabilityProbeResult, McpServerDiagnostic } from "../app-server/compatibility";
export interface DiagnosticRow {
label: string;
@ -9,16 +9,18 @@ export interface DiagnosticRow {
level?: "normal" | "warning" | "error";
}
export type DiagnosticAlertLevel = "normal" | "warning" | "error";
export interface ConnectionDiagnosticsInput {
connected: boolean;
configuredCommand: string;
initializeResponse: InitializeResponse | null;
activeThreadCliVersion: string | null;
compatibility: AppServerCompatibility;
diagnostics: AppServerDiagnostics;
}
export function connectionDiagnosticRows(input: ConnectionDiagnosticsInput): DiagnosticRow[] {
const rows: DiagnosticRow[] = [
return [
{ label: "connection", value: input.connected ? "connected" : "offline" },
{ label: "configured command", value: input.configuredCommand },
{ label: "running app-server", value: appServerIdentity(input.initializeResponse) },
@ -26,18 +28,57 @@ export function connectionDiagnosticRows(input: ConnectionDiagnosticsInput): Dia
{ label: "platform", value: appServerPlatform(input.initializeResponse) },
{ label: "codexHome", value: input.initializeResponse?.codexHome ?? "(not connected)" },
{ label: "active thread CLI", value: input.activeThreadCliVersion ?? "(none)" },
{
label: "compatibility",
value: compatibilitySummary(input.compatibility),
level: input.compatibility.modelList === "failed" ? "error" : "normal",
},
...CAPABILITY_PROBE_METHODS.map((method) => capabilityDiagnosticRow(input.diagnostics.probes[method])),
...mcpServerDiagnosticRows(input.diagnostics.mcpServers),
];
if (input.compatibility.modelListError) {
rows.push({ label: "model/list error", value: input.compatibility.modelListError, level: "error" });
}
return rows;
}
export function connectionDiagnosticLines(rows: DiagnosticRow[]): string[] {
return ["Connection diagnostics", ...rows.map((row) => `${row.label}: ${row.value}`)];
}
export function diagnosticAlertLevel(diagnostics: AppServerDiagnostics): DiagnosticAlertLevel {
let hasWarning = false;
for (const probe of Object.values(diagnostics.probes)) {
if (probe.status === "failed") return "error";
}
for (const server of diagnostics.mcpServers) {
if (server.startupStatus === "failed") return "error";
if (server.authStatus === "notLoggedIn") hasWarning = true;
}
return hasWarning ? "warning" : "normal";
}
function capabilityDiagnosticRow(probe: CapabilityProbeResult): DiagnosticRow {
const detail = probe.message ? ` - ${probe.message}` : probe.summary ? ` (${probe.summary})` : "";
return {
label: `capability ${probe.method}`,
value: `${probe.status}${detail}`,
level: capabilityLevel(probe.status),
};
}
function mcpServerDiagnosticRows(servers: McpServerDiagnostic[]): DiagnosticRow[] {
return servers.filter(isMcpServerIssue).map((server) => ({
label: `mcp ${server.name}`,
value: mcpServerDiagnosticValue(server),
level: server.startupStatus === "failed" ? "error" : "warning",
}));
}
function capabilityLevel(status: CapabilityProbeResult["status"]): DiagnosticRow["level"] {
if (status === "failed") return "error";
if (status === "unsupported" || status === "unknown") return "warning";
return "normal";
}
function isMcpServerIssue(server: McpServerDiagnostic): boolean {
return server.startupStatus === "failed" || server.authStatus === "notLoggedIn";
}
function mcpServerDiagnosticValue(server: McpServerDiagnostic): string {
const parts: string[] = [server.startupStatus];
if (server.authStatus) parts.push(`auth ${server.authStatus}`);
if (server.message) parts.push(server.message);
return parts.join(" - ");
}

View file

@ -1,4 +1,11 @@
import type { AppServerClient } from "../app-server/client";
import {
capabilityProbeError,
capabilityProbeOk,
upsertMcpServerDiagnostic,
type CapabilityProbeMethod,
} from "../app-server/compatibility";
import type { McpServerStatus } from "../generated/app-server/v2/McpServerStatus";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../runtime/state";
import type { PanelState } from "./state";
@ -7,9 +14,6 @@ export interface PanelSessionControllerHost {
vaultPath: string;
currentClient: () => AppServerClient | null;
runtimeSnapshot: () => RuntimeSnapshot;
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
addDedupedSystemMessage: (text: string) => void;
forceMessagesToBottom: () => void;
}
@ -57,14 +61,8 @@ export class PanelSessionController {
try {
const response = await client.listModels(false);
this.host.state.availableModels = response.data;
this.host.state.appServerCompatibility.modelList = "ok";
this.host.state.appServerCompatibility.modelListError = null;
} catch (error) {
} catch {
this.host.state.availableModels = [];
const message = error instanceof Error ? error.message : String(error);
this.host.state.appServerCompatibility.modelList = "failed";
this.host.state.appServerCompatibility.modelListError = message;
this.host.addDedupedSystemMessage(`Could not load Codex models: ${message}`);
}
}
@ -74,9 +72,8 @@ export class PanelSessionController {
try {
const response = await client.listSkills(this.host.vaultPath, forceReload);
this.host.state.availableSkills = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
} catch (error) {
} catch {
this.host.state.availableSkills = [];
this.host.addDedupedSystemMessage(`Could not load Codex skills: ${error instanceof Error ? error.message : String(error)}`);
}
}
@ -90,4 +87,99 @@ export class PanelSessionController {
this.host.state.rateLimit = null;
}
}
async refreshCapabilityDiagnostics(): Promise<void> {
const client = this.host.currentClient();
if (!client) return;
await Promise.all([
this.probeCapability(
"model/list",
() => client.listModels(false),
(response) => `${response.data.length} models`,
),
this.probeCapability(
"skills/list",
() => client.listSkills(this.host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return `${count} skills`;
},
),
this.probeCapability(
"hooks/list",
() => client.listHooks(this.host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
return `${count} hooks`;
},
),
this.probeCapability(
"account/rateLimits/read",
() => client.readAccountRateLimits(),
(response) => (response.rateLimitsByLimitId ? `${Object.keys(response.rateLimitsByLimitId).length} limits` : "available"),
),
this.probeCapability(
"mcpServerStatus/list",
() => client.listMcpServerStatus(),
(response) => {
this.recordMcpServerStatus(response.data);
const issueCount = response.data.filter((server) => server.authStatus === "notLoggedIn").length;
return issueCount > 0 ? `${response.data.length} servers, ${issueCount} auth issues` : `${response.data.length} servers`;
},
),
this.probeCapability(
"collaborationMode/list",
() => client.listCollaborationModes(),
(response) => `${response.data.length} modes`,
),
this.probeCapability(
"modelProvider/capabilities/read",
() => client.readModelProviderCapabilities(),
(response) =>
[
response.namespaceTools ? "namespace tools" : null,
response.imageGeneration ? "image generation" : null,
response.webSearch ? "web search" : null,
]
.filter(Boolean)
.join(", ") || "no optional capabilities",
),
]);
}
recordMcpStartupStatus(name: string, startupStatus: "starting" | "ready" | "failed" | "cancelled", message: string | null): void {
this.host.state.appServerDiagnostics = upsertMcpServerDiagnostic(this.host.state.appServerDiagnostics, {
name,
startupStatus,
authStatus: null,
toolCount: null,
message,
});
}
private async probeCapability<T>(
method: CapabilityProbeMethod,
request: () => Promise<T>,
summarize: (response: T) => string | null,
): Promise<void> {
try {
const response = await request();
this.host.state.appServerDiagnostics.probes[method] = capabilityProbeOk(method, summarize(response));
} catch (error) {
this.host.state.appServerDiagnostics.probes[method] = capabilityProbeError(method, error);
}
}
private recordMcpServerStatus(servers: McpServerStatus[]): void {
for (const server of servers) {
this.host.state.appServerDiagnostics = upsertMcpServerDiagnostic(this.host.state.appServerDiagnostics, {
name: server.name,
startupStatus: "unknown",
authStatus: server.authStatus,
toolCount: Object.keys(server.tools ?? {}).length,
message: null,
});
}
}
}

View file

@ -7,8 +7,8 @@ import type { RateLimitSnapshot } from "../generated/app-server/v2/RateLimitSnap
import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import type { ThreadTokenUsage } from "../generated/app-server/v2/ThreadTokenUsage";
import type { AppServerCompatibility } from "../app-server/compatibility";
import { createAppServerCompatibility } from "../app-server/compatibility";
import type { AppServerDiagnostics } from "../app-server/compatibility";
import { createAppServerDiagnostics } from "../app-server/compatibility";
import type { PendingApproval } from "../approvals/model";
import type { ComposerSuggestion } from "../composer/suggestions";
import type { DisplayItem } from "../display/types";
@ -26,7 +26,7 @@ export interface PanelState {
activeModel: string | null;
activeServiceTier: string | null;
activeThreadCliVersion: string | null;
appServerCompatibility: AppServerCompatibility;
appServerDiagnostics: AppServerDiagnostics;
requestedModel: RuntimeOverride<string>;
requestedReasoningEffort: RuntimeOverride<ReasoningEffort>;
requestedCollaborationMode: ModeKind;
@ -47,7 +47,6 @@ export interface PanelState {
runtimePicker: "model" | "effort" | null;
availableModels: Model[];
availableSkills: SkillMetadata[];
reportedMcpFailures: Set<string>;
reportedLogs: Set<string>;
composerSuggestSelected: number;
composerSuggestions: ComposerSuggestion[];
@ -67,7 +66,7 @@ export function createPanelState(): PanelState {
activeModel: null,
activeServiceTier: null,
activeThreadCliVersion: null,
appServerCompatibility: createAppServerCompatibility(),
appServerDiagnostics: createAppServerDiagnostics(),
requestedModel: defaultRuntimeOverride(),
requestedReasoningEffort: defaultRuntimeOverride(),
requestedCollaborationMode: "default",
@ -88,7 +87,6 @@ export function createPanelState(): PanelState {
runtimePicker: null,
availableModels: [],
availableSkills: [],
reportedMcpFailures: new Set(),
reportedLogs: new Set(),
composerSuggestSelected: 0,
composerSuggestions: [],
@ -130,5 +128,7 @@ export function clearConnectionScopedState(state: PanelState): void {
state.listedThreads = [];
state.threadsLoaded = false;
state.availableModels = [];
state.availableSkills = [];
state.appServerDiagnostics = createAppServerDiagnostics();
state.runtimePicker = null;
}

View file

@ -15,7 +15,7 @@ import {
nextCollaborationMode,
} from "../runtime/collaboration-mode";
import { PanelController } from "./controller";
import { connectionDiagnosticLines, connectionDiagnosticRows } from "./diagnostics";
import { connectionDiagnosticLines, connectionDiagnosticRows, diagnosticAlertLevel } from "./diagnostics";
import { rollbackCandidateFromItems } from "./rollback";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../runtime/view";
import {
@ -138,6 +138,10 @@ export class CodexPanelView extends ItemView {
refreshThreads: () => void this.refreshThreads(),
refreshSkills: (forceReload) => void this.refreshSkills(forceReload),
maybeNameThread: (threadId, turn) => this.threadRename.maybeAutoNameThread(threadId, turn),
recordMcpStartupStatus: (name, status, message) => {
this.session.recordMcpStartupStatus(name, status, message);
this.scheduleRender();
},
respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result),
rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message),
});
@ -146,9 +150,6 @@ export class CodexPanelView extends ItemView {
vaultPath: this.plugin.vaultPath,
currentClient: () => this.connection.currentClient(),
runtimeSnapshot: () => this.runtimeSnapshot(),
setStatus: (status) => this.setStatus(status),
addSystemMessage: (text) => this.addSystemMessage(text),
addDedupedSystemMessage: (text) => this.addDedupedSystemMessage(text),
forceMessagesToBottom: () => this.forceMessagesToBottom(),
});
this.history = new ThreadHistoryLoader({
@ -230,6 +231,7 @@ export class CodexPanelView extends ItemView {
this.client = this.connection.currentClient();
if (!this.client) throw new Error("Codex app-server connection did not initialize.");
await this.session.refreshSessionMetadata();
await this.session.refreshCapabilityDiagnostics();
await this.session.refreshThreadList();
this.refreshTabHeader();
this.setStatus("Connected.");
@ -276,6 +278,14 @@ export class CodexPanelView extends ItemView {
}
}
private async refreshDiagnostics(): Promise<void> {
const alreadyConnected = this.connection.isConnected();
await this.ensureConnected();
if (!this.client) return;
if (alreadyConnected) await this.session.refreshCapabilityDiagnostics();
this.render();
}
private async refreshSkills(forceReload = false): Promise<void> {
this.client = this.connection.currentClient();
if (!this.client) return;
@ -641,6 +651,7 @@ export class CodexPanelView extends ItemView {
toggleFast: () => this.toggleFastMode(),
toggleRuntime: () => this.toggleRuntimePicker("model"),
connect: () => void this.reconnectFromToolbar(),
refreshDiagnostics: () => void this.refreshDiagnostics(),
refreshThreads: () => {
this.state.openDetails.delete("status-panel");
void this.refreshThreads();
@ -703,6 +714,7 @@ export class CodexPanelView extends ItemView {
effortChoices: this.effortToolbarChoices(),
connectLabel: this.connection.isConnected() ? "Reconnect" : "Connect",
diagnostics: this.connectionDiagnosticRows(),
diagnosticAlertLevel: diagnosticAlertLevel(this.state.appServerDiagnostics),
};
}
@ -878,7 +890,7 @@ export class CodexPanelView extends ItemView {
configuredCommand: this.plugin.settings.codexPath,
initializeResponse: this.state.initializeResponse,
activeThreadCliVersion: this.state.activeThreadCliVersion,
compatibility: this.state.appServerCompatibility,
diagnostics: this.state.appServerDiagnostics,
});
}

View file

@ -6,6 +6,7 @@ import { renderEffectiveConfig } from "./config";
export type ToolbarPanelKind = "history" | "status" | "runtime";
export type ToolbarStatusState = "offline" | "connected" | "running";
export type ToolbarDiagnosticAlertLevel = "normal" | "warning" | "error";
export interface ToolbarChoice {
label: string;
@ -56,6 +57,7 @@ export interface ToolbarViewModel {
effortChoices: ToolbarChoice[];
connectLabel: string;
diagnostics: ToolbarDiagnosticRow[];
diagnosticAlertLevel: ToolbarDiagnosticAlertLevel;
}
export interface ToolbarActions {
@ -65,6 +67,7 @@ export interface ToolbarActions {
toggleFast: () => void;
toggleRuntime: () => void;
connect: () => void;
refreshDiagnostics: () => void;
refreshThreads: () => void;
resumeThread: (threadId: string) => void;
archiveThread: (threadId: string) => void;
@ -104,6 +107,7 @@ export function toolbarSignature(model: ToolbarViewModel): string {
effortChoices: model.effortChoices.map((choice) => `${choice.label}:${choice.selected}:${choice.disabled}:${choice.meta ?? ""}`),
connectLabel: model.connectLabel,
diagnostics: model.diagnostics.map((row) => `${row.label}:${row.value}:${row.level ?? "normal"}`),
diagnosticAlertLevel: model.diagnosticAlertLevel,
});
}
@ -128,18 +132,34 @@ function renderHistoryButton(parent: HTMLElement, model: ToolbarViewModel, actio
}
function renderStatusButton(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const label = `Status: ${model.status}; ${model.connected ? "connected" : "not connected"}`;
const alertClass = model.diagnosticAlertLevel === "normal" ? "" : ` codex-panel__status-dot--diagnostic-${model.diagnosticAlertLevel}`;
const button = parent.createEl("button", {
cls: `clickable-icon nav-action-button codex-panel__top-control codex-panel__status-dot codex-panel__status-dot--${model.statusState} ${model.statusPanelOpen ? "is-active" : ""}`,
cls: `clickable-icon nav-action-button codex-panel__top-control codex-panel__status-dot codex-panel__status-dot--${model.statusState}${alertClass} ${model.statusPanelOpen ? "is-active" : ""}`,
attr: {
type: "button",
"aria-label": label,
"aria-label": statusButtonLabel(model),
"aria-expanded": model.statusPanelOpen ? "true" : "false",
},
});
if (model.diagnosticAlertLevel !== "normal") {
button.createSpan({
cls: `codex-panel__status-dot-diagnostic codex-panel__status-dot-diagnostic--${model.diagnosticAlertLevel}`,
attr: { "aria-hidden": "true" },
});
}
button.onclick = actions.toggleStatusPanel;
}
function statusButtonLabel(model: ToolbarViewModel): string {
const status = model.status.trim() || (model.connected ? "Connected" : "Not connected");
const connection = model.connected ? "connected" : "not connected";
const normalizedStatus = status.toLowerCase().replace(/[.!]+$/u, "");
const parts = [`Status: ${status}`];
if (normalizedStatus !== connection) parts.push(`Connection: ${connection}`);
parts.push(`Diagnostics: ${model.diagnosticAlertLevel}`);
return parts.join("; ");
}
function renderRuntimeStatus(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const row = parent.createDiv({ cls: "codex-panel__runtime-strip" });
renderRuntimeIcon(row, "list-checks", `Plan mode: ${model.planActive ? "on" : "off"}`, model.planActive, actions.togglePlan);
@ -215,6 +235,10 @@ function renderToolbarPanel(toolbar: HTMLElement, model: ToolbarViewModel, actio
function renderStatusPanel(parent: HTMLElement, model: ToolbarViewModel, actions: ToolbarActions): void {
const statusItems = parent.createDiv({ cls: "codex-panel__status-panel-items", attr: { role: "menu" } });
createToolbarPanelItem(statusItems, model.connectLabel, { onClick: actions.connect, className: "codex-panel__status-panel-item" });
createToolbarPanelItem(statusItems, "Refresh diagnostics", {
onClick: actions.refreshDiagnostics,
className: "codex-panel__status-panel-item",
});
createToolbarPanelItem(statusItems, "Refresh thread list", {
onClick: actions.refreshThreads,
className: "codex-panel__status-panel-item",

View file

@ -358,6 +358,7 @@
.codex-panel__status-dot {
grid-column: 4;
cursor: pointer;
position: relative;
}
.codex-panel__status-dot::before {
@ -379,6 +380,24 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-accent) 14%, transparent);
}
.codex-panel__status-dot-diagnostic {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border: 1px solid var(--background-primary);
border-radius: 999px;
}
.codex-panel__status-dot-diagnostic--warning {
background: var(--text-warning);
}
.codex-panel__status-dot-diagnostic--error {
background: var(--text-error);
}
.codex-panel__meter-compact {
display: flex;
align-items: center;

View file

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppServerClient } from "../src/app-server/client";
import type { AppServerRpcError } from "../src/app-server/client";
import type { AppServerTransport, AppServerTransportHandlers } from "../src/app-server/transport";
import type { RpcOutboundMessage } from "../src/app-server/types";
import type { InitializeResponse } from "../src/generated/app-server/InitializeResponse";
@ -311,6 +312,51 @@ describe("AppServerClient", () => {
await listing;
});
it("sends diagnostic capability request payloads", async () => {
const { client, transport } = await connectedClient();
const mcpStatus = client.listMcpServerStatus();
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "mcpServerStatus/list",
params: { detail: "toolsAndAuthOnly", limit: 100 },
});
transport.emitLine({ id: 2, result: { data: [], nextCursor: null } });
await mcpStatus;
const collaborationModes = client.listCollaborationModes();
expect(transport.sent[3]).toMatchObject({
id: 3,
method: "collaborationMode/list",
params: {},
});
transport.emitLine({ id: 3, result: { data: [] } });
await collaborationModes;
const capabilities = client.readModelProviderCapabilities();
expect(transport.sent[4]).toMatchObject({
id: 4,
method: "modelProvider/capabilities/read",
params: {},
});
transport.emitLine({ id: 4, result: { namespaceTools: true, imageGeneration: false, webSearch: true } });
await capabilities;
});
it("preserves app-server RPC error codes", async () => {
const { client, transport } = await connectedClient();
const listing = client.listModels();
transport.emitLine({ id: 2, error: { code: -32601, message: "Method not found" } });
await expect(listing).rejects.toMatchObject({
name: "AppServerRpcError",
code: -32601,
method: "model/list",
message: "Method not found",
} satisfies Partial<AppServerRpcError>);
});
it("sends golden list and history request payloads", async () => {
const { client, transport } = await connectedClient();

View file

@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { appServerIdentity, appServerPlatform, compatibilitySummary, createAppServerCompatibility } from "../src/app-server/compatibility";
import { AppServerRpcError } from "../src/app-server/client";
import {
CAPABILITY_PROBE_METHODS,
appServerIdentity,
appServerPlatform,
capabilityProbeError,
capabilityProbeOk,
createAppServerDiagnostics,
shortErrorMessage,
upsertMcpServerDiagnostic,
} from "../src/app-server/compatibility";
import type { InitializeResponse } from "../src/generated/app-server/InitializeResponse";
describe("app-server compatibility", () => {
@ -16,12 +26,87 @@ describe("app-server compatibility", () => {
expect(appServerPlatform(response)).toBe("macos/unix");
});
it("formats lightweight probe status", () => {
const compatibility = createAppServerCompatibility();
expect(compatibilitySummary(compatibility)).toBe("model/list unknown; Plan mode uses experimental collaborationMode override");
it("creates generic capability probe defaults", () => {
const diagnostics = createAppServerDiagnostics();
compatibility.modelList = "failed";
compatibility.modelListError = "Unsupported app-server request";
expect(compatibilitySummary(compatibility)).toBe("model/list failed; Plan mode uses experimental collaborationMode override");
expect(Object.keys(diagnostics.probes)).toEqual([...CAPABILITY_PROBE_METHODS]);
expect(diagnostics.probes["model/list"]).toMatchObject({
method: "model/list",
status: "unknown",
message: null,
summary: null,
checkedAt: null,
});
});
it("classifies ok, failed, and unsupported capability probes", () => {
expect(capabilityProbeOk("skills/list", "3 skills", 123)).toEqual({
method: "skills/list",
status: "ok",
message: null,
summary: "3 skills",
checkedAt: 123,
});
expect(capabilityProbeError("hooks/list", new Error("boom"), 456)).toMatchObject({
method: "hooks/list",
status: "failed",
message: "boom",
checkedAt: 456,
});
expect(
capabilityProbeError(
"collaborationMode/list",
new AppServerRpcError("collaborationMode/list", { code: -32601, message: "No method" }),
789,
),
).toMatchObject({
method: "collaborationMode/list",
status: "unsupported",
message: "No method",
checkedAt: 789,
});
expect(capabilityProbeError("modelProvider/capabilities/read", new Error("unknown method"), 790).status).toBe("unsupported");
expect(capabilityProbeError("modelProvider/capabilities/read", new Error("unsupported RPC method"), 791).status).toBe("unsupported");
expect(capabilityProbeError("model/list", new Error("unknown provider failure"), 792).status).toBe("failed");
});
it("shortens error messages and tracks MCP server diagnostics", () => {
expect(shortErrorMessage("a\n b\t c")).toBe("a b c");
expect(shortErrorMessage("x".repeat(200))).toHaveLength(160);
let diagnostics = upsertMcpServerDiagnostic(createAppServerDiagnostics(), {
name: "github",
startupStatus: "failed",
authStatus: null,
toolCount: null,
message: "missing token",
});
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: "github",
startupStatus: "unknown",
authStatus: "notLoggedIn",
toolCount: 2,
message: null,
});
expect(diagnostics.mcpServers).toEqual([
{ name: "github", startupStatus: "failed", authStatus: "notLoggedIn", toolCount: 2, message: "missing token" },
]);
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: "github",
startupStatus: "ready",
authStatus: null,
toolCount: null,
message: null,
});
expect(diagnostics.mcpServers[0]).toEqual({
name: "github",
startupStatus: "ready",
authStatus: "notLoggedIn",
toolCount: 2,
message: null,
});
});
});

89
tests/diagnostics.test.ts Normal file
View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
capabilityProbeError,
capabilityProbeOk,
createAppServerDiagnostics,
upsertMcpServerDiagnostic,
} from "../src/app-server/compatibility";
import { connectionDiagnosticLines, connectionDiagnosticRows, diagnosticAlertLevel } from "../src/panel/diagnostics";
describe("connection diagnostics", () => {
it("formats base rows, capability probes, and MCP issues for /doctor", () => {
let diagnostics = createAppServerDiagnostics();
diagnostics.probes["model/list"] = capabilityProbeOk("model/list", "12 models", 1);
diagnostics.probes["skills/list"] = capabilityProbeError("skills/list", new Error("unknown method skills/list"), 2);
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: "github",
startupStatus: "failed",
authStatus: null,
toolCount: null,
message: "missing token",
});
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: "docs",
startupStatus: "ready",
authStatus: "notLoggedIn",
toolCount: 2,
message: null,
});
const rows = connectionDiagnosticRows({
connected: true,
configuredCommand: "/opt/homebrew/bin/codex",
initializeResponse: {
userAgent: "codex-cli/0.130.0",
codexHome: "/Users/showhey/.codex",
platformFamily: "unix",
platformOs: "macos",
},
activeThreadCliVersion: "0.130.0",
diagnostics,
});
expect(rows.map((row) => `${row.label}: ${row.value}`)).toEqual(
expect.arrayContaining([
"connection: connected",
"capability model/list: ok (12 models)",
"capability skills/list: unsupported - unknown method skills/list",
"mcp docs: ready - auth notLoggedIn",
"mcp github: failed - missing token",
]),
);
expect(rows.find((row) => row.label === "capability skills/list")?.level).toBe("warning");
expect(rows.find((row) => row.label === "mcp github")?.level).toBe("error");
expect(connectionDiagnosticLines(rows)[0]).toBe("Connection diagnostics");
});
it("derives a top-level diagnostic alert without treating unknown or unsupported probes as warnings", () => {
expect(diagnosticAlertLevel(createAppServerDiagnostics())).toBe("normal");
const unsupported = createAppServerDiagnostics();
unsupported.probes["skills/list"] = capabilityProbeError("skills/list", new Error("unknown method skills/list"), 1);
expect(diagnosticAlertLevel(unsupported)).toBe("normal");
const failed = createAppServerDiagnostics();
failed.probes["model/list"] = capabilityProbeError("model/list", new Error("network down"), 1);
expect(diagnosticAlertLevel(failed)).toBe("error");
});
it("derives MCP diagnostic alerts with error priority", () => {
let authIssue = upsertMcpServerDiagnostic(createAppServerDiagnostics(), {
name: "docs",
startupStatus: "ready",
authStatus: "notLoggedIn",
toolCount: 0,
message: null,
});
expect(diagnosticAlertLevel(authIssue)).toBe("warning");
authIssue = upsertMcpServerDiagnostic(authIssue, {
name: "github",
startupStatus: "failed",
authStatus: null,
toolCount: null,
message: "missing token",
});
expect(diagnosticAlertLevel(authIssue)).toBe("error");
});
});

View file

@ -15,6 +15,7 @@ function controllerForState(
refreshThreads: vi.fn(),
refreshSkills: vi.fn(),
maybeNameThread: vi.fn(),
recordMcpStartupStatus: vi.fn(),
respondToServerRequest: vi.fn(() => true),
rejectServerRequest: vi.fn(() => true),
...actions,
@ -252,6 +253,24 @@ describe("PanelController", () => {
});
});
it("records MCP startup status for diagnostics without a chat system message", () => {
const state = createPanelState();
const recordMcpStartupStatus = vi.fn();
const controller = controllerForState(state, { recordMcpStartupStatus });
controller.handleNotification({
method: "mcpServer/startupStatus/updated",
params: {
name: "github",
status: "failed",
error: "missing token",
},
} satisfies Extract<ServerNotification, { method: "mcpServer/startupStatus/updated" }>);
expect(recordMcpStartupStatus).toHaveBeenCalledWith("github", "failed", "missing token");
expect(state.displayItems).toEqual([]);
});
it("queues and resolves requestUserInput server requests", () => {
const state = createPanelState();
const respondToServerRequest = vi.fn(() => true);

View file

@ -1235,9 +1235,16 @@ describe("view renderers", () => {
const statusButton = parent.querySelector(".codex-panel__status-dot");
expect(statusButton?.tagName).toBe("BUTTON");
expect(statusButton?.getAttribute("role")).toBeNull();
expect(statusButton?.getAttribute("aria-label")).toBe("Status: Connected.; Diagnostics: normal");
parent.querySelector<HTMLButtonElement>(".codex-panel__history-toggle")?.click();
expect(toggleHistory).toHaveBeenCalled();
parent.empty();
renderToolbar(parent, toolbarModel({ status: "Turn running...", statusState: "running" }), toolbarActions());
expect(parent.querySelector(".codex-panel__status-dot")?.getAttribute("aria-label")).toBe(
"Status: Turn running...; Connection: connected; Diagnostics: normal",
);
expect(toolbarSignature(baseModel)).not.toBe(toolbarSignature({ ...baseModel, status: "Turn running..." }));
expect(toolbarSignature(baseModel)).not.toBe(
toolbarSignature({ ...baseModel, effortChoices: [...baseModel.effortChoices, { label: "xhigh", onClick: vi.fn() }] }),
@ -1304,6 +1311,7 @@ describe("view renderers", () => {
it("renders connection diagnostics in the status menu", () => {
const parent = document.createElement("div");
const refreshDiagnostics = vi.fn();
renderToolbar(
parent,
@ -1315,13 +1323,40 @@ describe("view renderers", () => {
{ label: "compatibility", value: "model/list failed", level: "error" },
],
}),
toolbarActions(),
toolbarActions({ refreshDiagnostics }),
);
expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection diagnostics");
expect(parent.textContent).toContain("Effective Codex config");
expect(parent.textContent).toContain("Refresh diagnostics");
expect(parent.textContent).toContain("codex-cli/1.2.3");
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed");
[...parent.querySelectorAll<HTMLButtonElement>(".codex-panel__status-panel-item")]
.find((button) => button.textContent?.includes("Refresh diagnostics"))
?.click();
expect(refreshDiagnostics).toHaveBeenCalled();
});
it("renders diagnostic alert badges on the status dot", () => {
const normal = document.createElement("div");
renderToolbar(normal, toolbarModel({ diagnosticAlertLevel: "normal" }), toolbarActions());
const normalStatus = normal.querySelector(".codex-panel__status-dot");
expect(normalStatus?.querySelector(".codex-panel__status-dot-diagnostic")).toBeNull();
expect(normalStatus?.getAttribute("aria-label")).toContain("Diagnostics: normal");
const warning = document.createElement("div");
renderToolbar(warning, toolbarModel({ diagnosticAlertLevel: "warning" }), toolbarActions());
const warningStatus = warning.querySelector(".codex-panel__status-dot");
expect(warningStatus?.classList.contains("codex-panel__status-dot--diagnostic-warning")).toBe(true);
expect(warningStatus?.querySelector(".codex-panel__status-dot-diagnostic--warning")).not.toBeNull();
expect(warningStatus?.getAttribute("aria-label")).toContain("Diagnostics: warning");
const error = document.createElement("div");
renderToolbar(error, toolbarModel({ diagnosticAlertLevel: "error" }), toolbarActions());
const errorStatus = error.querySelector(".codex-panel__status-dot");
expect(errorStatus?.classList.contains("codex-panel__status-dot--diagnostic-error")).toBe(true);
expect(errorStatus?.querySelector(".codex-panel__status-dot-diagnostic--error")).not.toBeNull();
expect(errorStatus?.getAttribute("aria-label")).toContain("Diagnostics: error");
});
it("renders effective config inside the status menu without a separate toggle", () => {
@ -1758,6 +1793,7 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
effortChoices: [{ label: "Default", selected: true, onClick: vi.fn() }],
connectLabel: "Reconnect",
diagnostics: [{ label: "running app-server", value: "codex-cli/test" }],
diagnosticAlertLevel: "normal",
...overrides,
};
}
@ -1770,6 +1806,7 @@ function toolbarActions(overrides: Partial<Parameters<typeof renderToolbar>[2]>
toggleFast: vi.fn(),
toggleRuntime: vi.fn(),
connect: vi.fn(),
refreshDiagnostics: vi.fn(),
refreshThreads: vi.fn(),
resumeThread: vi.fn(),
archiveThread: vi.fn(),