mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add capability diagnostics
This commit is contained in:
parent
1bcaa55f82
commit
56dba17bde
14 changed files with 655 additions and 67 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(" - ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
19
styles.css
19
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
89
tests/diagnostics.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue