Add tool source diagnostics

This commit is contained in:
murashit 2026-06-20 14:46:47 +09:00
parent 58e24197b3
commit 759adabb9c
33 changed files with 1284 additions and 385 deletions

View file

@ -83,7 +83,7 @@ By default, `Enter` sends and `Shift+Enter` inserts a newline. You can switch se
- Inspect context usage from the composer status row.
- Toggle Plan mode, fast mode, and approval auto-review for subsequent turns.
- Set the model and reasoning effort for subsequent turns when supported by Codex.
- Inspect usage limits, connection diagnostics, MCP servers, enabled skills, runtime debug details, and discovered hooks from the toolbar, settings, or slash commands.
- Inspect usage limits, connection diagnostics, and Codex capabilities from the toolbar or slash commands; copy runtime debug details from the status panel; view discovered hooks in settings.
### Obsidian-native workflows
@ -150,7 +150,7 @@ Use `/help` in the composer to show the available slash commands. The list below
| --------- | ------------------------------------------------ |
| `/status` | Show current thread, context, and usage limits. |
| `/doctor` | Show Codex CLI and Codex App Server diagnostics. |
| `/mcp` | Show MCP servers reported by Codex App Server. |
| `/tools` | Show Codex plugins, tool providers, and skills. |
| `/help` | Show available slash commands. |
#### Composition

View file

@ -28,7 +28,7 @@ export async function listSkillCatalog(
cwd: string,
options: { forceReload?: boolean; enabledOnly?: boolean } = {},
): Promise<{ skills: SkillMetadata[]; totalCount: number }> {
const response = await client.listSkills(cwd, options.forceReload ?? false);
const response = options.forceReload === undefined ? await client.listSkills(cwd) : await client.listSkills(cwd, options.forceReload);
const skills = response.data.flatMap((entry) => entry.skills);
return {
skills: skillMetadataFromCatalogSkills(options.enabledOnly === false ? skills : skills.filter((skill) => skill.enabled)),

View file

@ -3,6 +3,8 @@ import type { CollaborationMode } from "../../generated/app-server/Collaboration
import type { RequestId } from "../../generated/app-server/RequestId";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
import type { AppsListParams } from "../../generated/app-server/v2/AppsListParams";
import type { AppsListResponse } from "../../generated/app-server/v2/AppsListResponse";
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWriteResponse";
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
@ -13,6 +15,10 @@ import type { ListMcpServerStatusParams } from "../../generated/app-server/v2/Li
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 { PluginInstalledParams } from "../../generated/app-server/v2/PluginInstalledParams";
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
import type { PluginReadParams } from "../../generated/app-server/v2/PluginReadParams";
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
import type { ThreadArchiveResponse } from "../../generated/app-server/v2/ThreadArchiveResponse";
import type { ThreadDeleteResponse } from "../../generated/app-server/v2/ThreadDeleteResponse";
@ -131,6 +137,9 @@ interface ClientResponseByMethod {
"thread/settings/update": ThreadSettingsUpdateResponse;
"thread/turns/list": ThreadTurnsListResponse;
"skills/list": SkillsListResponse;
"app/list": AppsListResponse;
"plugin/installed": PluginInstalledResponse;
"plugin/read": PluginReadResponse;
"model/list": ModelListResponse;
"account/rateLimits/read": GetAccountRateLimitsResponse;
"mcpServerStatus/list": ListMcpServerStatusResponse;
@ -411,6 +420,19 @@ export class AppServerClient {
});
}
listApps(params: AppsListParams = { limit: 100 }): Promise<AppsListResponse> {
return this.request("app/list", params);
}
listInstalledPlugins(cwd: string): Promise<PluginInstalledResponse> {
const params: PluginInstalledParams = { cwds: [cwd] };
return this.request("plugin/installed", params);
}
readPlugin(params: PluginReadParams): Promise<PluginReadResponse> {
return this.request("plugin/read", params);
}
listModels(includeHidden = false): Promise<ModelListResponse> {
return this.request("model/list", {
includeHidden,

View file

@ -0,0 +1,113 @@
import type { ToolInventoryApp, ToolInventoryMarketplaceError, ToolInventoryPlugin } from "../../domain/server/tool-inventory";
interface ToolInventoryAppInfo {
id: string;
name: string;
description: string | null;
isEnabled: boolean;
isAccessible: boolean;
distributionChannel: string | null;
pluginDisplayNames: readonly string[];
[key: string]: unknown;
}
interface ToolInventoryPluginInstalledResponse {
marketplaces: readonly ToolInventoryPluginMarketplaceEntry[];
marketplaceLoadErrors: readonly ToolInventoryMarketplaceLoadError[];
[key: string]: unknown;
}
interface ToolInventoryPluginMarketplaceEntry {
name: string;
path: string | null;
plugins: readonly ToolInventoryPluginSummary[];
[key: string]: unknown;
}
interface ToolInventoryPluginSummary {
id: string;
name: string;
interface: { displayName: string | null } | null;
localVersion: string | null;
installed: boolean;
enabled: boolean;
availability: string;
source: ToolInventoryPluginSource;
[key: string]: unknown;
}
type ToolInventoryPluginSource =
| { type: "local"; path: string }
| { type: "git"; url: string; path: string | null; refName: string | null; sha: string | null }
| { type: "remote" };
interface ToolInventoryMarketplaceLoadError {
marketplacePath: string;
message: string;
[key: string]: unknown;
}
export function toolInventoryAppsFromAppInfos(apps: readonly ToolInventoryAppInfo[]): ToolInventoryApp[] {
return apps.map(toolInventoryAppFromAppInfo).sort((a, b) => a.name.localeCompare(b.name));
}
export function toolInventoryPluginsFromInstalledResponse(response: ToolInventoryPluginInstalledResponse): {
plugins: ToolInventoryPlugin[];
marketplaceErrors: ToolInventoryMarketplaceError[];
} {
return {
plugins: response.marketplaces
.flatMap((marketplace) => marketplace.plugins.map((plugin) => toolInventoryPluginFromSummary(plugin, marketplace)))
.sort((a, b) => a.name.localeCompare(b.name)),
marketplaceErrors: response.marketplaceLoadErrors.map(toolInventoryMarketplaceError),
};
}
function toolInventoryAppFromAppInfo(app: ToolInventoryAppInfo): ToolInventoryApp {
return {
id: app.id,
name: app.name,
description: app.description,
enabled: app.isEnabled,
accessible: app.isAccessible,
distributionChannel: app.distributionChannel,
pluginDisplayNames: [...app.pluginDisplayNames],
};
}
function toolInventoryPluginFromSummary(
plugin: ToolInventoryPluginSummary,
marketplace: ToolInventoryPluginMarketplaceEntry,
): ToolInventoryPlugin {
return {
id: plugin.id,
name: plugin.name,
displayName: plugin.interface?.displayName ?? null,
marketplaceName: marketplace.name,
marketplacePath: marketplace.path,
localVersion: plugin.localVersion,
installed: plugin.installed,
enabled: plugin.enabled,
availability: plugin.availability,
source: pluginSourceLabel(plugin.source),
details: null,
detailsError: null,
};
}
function toolInventoryMarketplaceError(error: ToolInventoryMarketplaceLoadError): ToolInventoryMarketplaceError {
return {
marketplacePath: error.marketplacePath,
message: error.message,
};
}
function pluginSourceLabel(source: ToolInventoryPluginSource): string {
if (source.type === "local") return source.path;
if (source.type === "git") {
const ref = source.refName ? `#${source.refName}` : source.sha ? `#${source.sha.slice(0, 8)}` : "";
const path = source.path ? `/${source.path}` : "";
return `${source.url}${path}${ref}`;
}
return "remote";
}

View file

@ -2,6 +2,7 @@ import type { ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
import type { SharedServerMetadata } from "../../domain/server/metadata";
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
import type { Thread } from "../../domain/threads/model";
export function cloneThreads(threads: readonly Thread[]): Thread[] {
@ -28,6 +29,7 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
serverDiagnostics: {
probes: { ...metadata.serverDiagnostics.probes },
mcpServers: metadata.serverDiagnostics.mcpServers.map((server) => ({ ...server })),
toolInventory: metadata.serverDiagnostics.toolInventory ? cloneToolInventorySnapshot(metadata.serverDiagnostics.toolInventory) : null,
},
};
}

View file

@ -0,0 +1,236 @@
import {
diagnosticProbeError,
diagnosticProbeOk,
mcpServerStatusSummariesFromStatuses,
type DiagnosticProbeResult,
type McpServerDiagnostic,
type McpServerStatusSummary,
} from "../domain/server/diagnostics";
import type { SkillMetadata } from "../domain/catalog/metadata";
import type {
ToolInventoryApp,
ToolInventoryMarketplaceError,
ToolInventoryPlugin,
ToolInventorySnapshot,
} from "../domain/server/tool-inventory";
import { listSkillCatalog } from "./catalog";
import type { AppServerClient } from "./connection/client";
import { toolInventoryAppsFromAppInfos, toolInventoryPluginsFromInstalledResponse } from "./protocol/tool-inventory";
const APP_PAGE_LIMIT = 100;
const APP_PAGE_LOOP_LIMIT = 20;
export interface ReadToolInventoryOptions {
readonly threadId?: string | null;
readonly mcpDiagnostics?: readonly McpServerDiagnostic[];
readonly cachedSkills?: readonly SkillMetadata[];
readonly cachedSkillsProbe?: DiagnosticProbeResult;
}
export interface ReadToolInventoryResult {
readonly inventory: ToolInventorySnapshot;
readonly probes: readonly DiagnosticProbeResult[];
readonly mcpServerStatuses: readonly McpServerStatusSummary[] | null;
}
export async function readToolInventory(
client: AppServerClient,
cwd: string,
options: ReadToolInventoryOptions = {},
): Promise<ReadToolInventoryResult> {
const checkedAt = Date.now();
const [apps, plugins, mcp, skills] = await Promise.all([
readApps(client, options.threadId ?? null, checkedAt),
readPlugins(client, cwd, checkedAt),
readMcpServers(client, options.threadId ?? null, checkedAt),
options.cachedSkills !== undefined
? Promise.resolve(readCachedSkills(options.cachedSkills, options.cachedSkillsProbe, checkedAt))
: readSkills(client, cwd, checkedAt),
]);
return {
inventory: {
checkedAt,
apps: apps.data,
appsError: apps.error,
plugins: plugins.data,
pluginMarketplaceErrors: plugins.marketplaceErrors,
pluginsError: plugins.error,
mcpServers: mcp.data,
mcpDiagnostics: options.mcpDiagnostics ?? [],
mcpError: mcp.error,
skills: skills.data,
skillsError: skills.error,
},
probes: [apps.probe, plugins.probe, mcp.probe, skills.probe],
mcpServerStatuses: mcp.data,
};
}
function readCachedSkills(
skills: readonly SkillMetadata[],
probe: DiagnosticProbeResult | undefined,
checkedAt: number,
): { data: ToolInventorySnapshot["skills"]; error: string | null; probe: DiagnosticProbeResult } {
return {
data: [...skills],
error: null,
probe: probe ?? diagnosticProbeOk("skills/list", `${String(skills.length)} skills`, checkedAt),
};
}
async function readApps(
client: AppServerClient,
threadId: string | null,
checkedAt: number,
): Promise<{ data: ToolInventoryApp[] | null; error: string | null; probe: DiagnosticProbeResult }> {
try {
const apps = await listAllApps(client, threadId);
return {
data: toolInventoryAppsFromAppInfos(apps),
error: null,
probe: diagnosticProbeOk("app/list", `${String(apps.length)} apps`, checkedAt),
};
} catch (error) {
return { data: null, error: shortErrorMessage(error), probe: diagnosticProbeError("app/list", error, checkedAt) };
}
}
async function listAllApps(
client: AppServerClient,
threadId: string | null,
): Promise<Awaited<ReturnType<AppServerClient["listApps"]>>["data"]> {
let cursor: string | null = null;
const apps: Awaited<ReturnType<AppServerClient["listApps"]>>["data"] = [];
for (let page = 0; page < APP_PAGE_LOOP_LIMIT; page += 1) {
const response = await client.listApps({
limit: APP_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
...(threadId ? { threadId } : {}),
});
apps.push(...response.data);
cursor = response.nextCursor;
if (!cursor) return apps;
}
return apps;
}
async function readPlugins(
client: AppServerClient,
cwd: string,
checkedAt: number,
): Promise<{
data: ToolInventoryPlugin[] | null;
marketplaceErrors: ToolInventoryMarketplaceError[];
error: string | null;
probe: DiagnosticProbeResult;
}> {
try {
const response = await client.listInstalledPlugins(cwd);
const { plugins, marketplaceErrors } = toolInventoryPluginsFromInstalledResponse(response);
const pluginsWithDetails = await Promise.all(plugins.map((plugin) => readPluginDetails(client, plugin)));
return {
data: pluginsWithDetails,
marketplaceErrors,
error: null,
probe: diagnosticProbeOk("plugin/installed", `${String(plugins.length)} plugins`, checkedAt),
};
} catch (error) {
return {
data: null,
marketplaceErrors: [],
error: shortErrorMessage(error),
probe: diagnosticProbeError("plugin/installed", error, checkedAt),
};
}
}
async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
if (!isUsablePlugin(plugin)) return plugin;
try {
const response = await client.readPlugin(pluginReadParams(plugin));
return {
...plugin,
details: {
skillCount: response.plugin.skills.length,
hookCount: response.plugin.hooks.length,
appCount: response.plugin.apps.length,
mcpServerCount: response.plugin.mcpServers.length,
},
detailsError: null,
};
} catch (error) {
return { ...plugin, details: null, detailsError: shortErrorMessage(error) };
}
}
function pluginReadParams(plugin: ToolInventoryPlugin): Parameters<AppServerClient["readPlugin"]>[0] {
if (plugin.marketplacePath) {
return {
pluginName: plugin.name,
marketplacePath: plugin.marketplacePath,
};
}
return {
pluginName: plugin.name,
remoteMarketplaceName: plugin.marketplaceName,
};
}
function isUsablePlugin(plugin: ToolInventoryPlugin): boolean {
return plugin.enabled && plugin.installed;
}
async function readMcpServers(
client: AppServerClient,
threadId: string | null,
checkedAt: number,
): Promise<{ data: McpServerStatusSummary[] | null; error: string | null; probe: DiagnosticProbeResult }> {
try {
const response = await client.listMcpServerStatus({
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
});
const data = mcpServerStatusSummariesFromStatuses(response.data);
return {
data,
error: null,
probe: diagnosticProbeOk("mcpServerStatus/list", mcpSummary(data), checkedAt),
};
} catch (error) {
return {
data: null,
error: shortErrorMessage(error),
probe: diagnosticProbeError("mcpServerStatus/list", error, checkedAt),
};
}
}
function mcpSummary(servers: readonly McpServerStatusSummary[]): string {
const issueCount = servers.filter((server) => server.authStatus === "notLoggedIn").length;
return issueCount > 0 ? `${String(servers.length)} servers, ${String(issueCount)} auth issues` : `${String(servers.length)} servers`;
}
async function readSkills(
client: AppServerClient,
cwd: string,
checkedAt: number,
): Promise<{ data: ToolInventorySnapshot["skills"]; error: string | null; probe: DiagnosticProbeResult }> {
try {
const catalog = await listSkillCatalog(client, cwd, { enabledOnly: false });
return {
data: catalog.skills,
error: null,
probe: diagnosticProbeOk("skills/list", `${String(catalog.totalCount)} skills`, checkedAt),
};
} catch (error) {
return { data: null, error: shortErrorMessage(error), probe: diagnosticProbeError("skills/list", error, checkedAt) };
}
}
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;
}

View file

@ -1,13 +1,13 @@
import type { ServerInitialization } from "./initialization";
import { cloneToolInventorySnapshot, type ToolInventorySnapshot } from "./tool-inventory";
export const DIAGNOSTIC_PROBE_METHODS = [
"model/list",
"skills/list",
"hooks/list",
"app/list",
"plugin/installed",
"account/rateLimits/read",
"mcpServerStatus/list",
"collaborationMode/list",
"modelProvider/capabilities/read",
] as const;
export type DiagnosticProbeMethod = (typeof DIAGNOSTIC_PROBE_METHODS)[number];
@ -50,6 +50,7 @@ export interface McpServerStatusSummary {
export interface Diagnostics {
readonly probes: Readonly<Record<DiagnosticProbeMethod, DiagnosticProbeResult>>;
readonly mcpServers: readonly McpServerDiagnostic[];
readonly toolInventory: ToolInventorySnapshot | null;
}
export function createServerDiagnostics(): Diagnostics {
@ -59,6 +60,7 @@ export function createServerDiagnostics(): Diagnostics {
DiagnosticProbeResult
>,
mcpServers: [],
toolInventory: null,
};
}
@ -66,6 +68,7 @@ export function cloneServerDiagnostics(diagnostics: Diagnostics): Diagnostics {
return {
probes: { ...diagnostics.probes },
mcpServers: diagnostics.mcpServers.map((server) => ({ ...server })),
toolInventory: diagnostics.toolInventory ? cloneToolInventorySnapshot(diagnostics.toolInventory) : null,
};
}
@ -79,6 +82,13 @@ export function diagnosticsWithProbe(diagnostics: Diagnostics, probe: Diagnostic
};
}
export function diagnosticsWithToolInventory(diagnostics: Diagnostics, toolInventory: ToolInventorySnapshot | null): Diagnostics {
return {
...diagnostics,
toolInventory: toolInventory ? cloneToolInventorySnapshot(toolInventory) : null,
};
}
function createDiagnosticProbeResult(method: DiagnosticProbeMethod): DiagnosticProbeResult {
return {
method,

View file

@ -0,0 +1,65 @@
import type { SkillMetadata } from "../catalog/metadata";
import type { McpServerDiagnostic, McpServerStatusSummary } from "./diagnostics";
export interface ToolInventoryApp {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly enabled: boolean;
readonly accessible: boolean;
readonly distributionChannel: string | null;
readonly pluginDisplayNames: readonly string[];
}
export interface ToolInventoryPlugin {
readonly id: string;
readonly name: string;
readonly displayName: string | null;
readonly marketplaceName: string;
readonly marketplacePath: string | null;
readonly localVersion: string | null;
readonly installed: boolean;
readonly enabled: boolean;
readonly availability: string;
readonly source: string;
readonly details: {
readonly skillCount: number;
readonly hookCount: number;
readonly appCount: number;
readonly mcpServerCount: number;
} | null;
readonly detailsError: string | null;
}
export interface ToolInventoryMarketplaceError {
readonly marketplacePath: string;
readonly message: string;
}
export interface ToolInventorySnapshot {
readonly checkedAt: number;
readonly apps: readonly ToolInventoryApp[] | null;
readonly appsError: string | null;
readonly plugins: readonly ToolInventoryPlugin[] | null;
readonly pluginMarketplaceErrors: readonly ToolInventoryMarketplaceError[];
readonly pluginsError: string | null;
readonly mcpServers: readonly McpServerStatusSummary[] | null;
readonly mcpDiagnostics: readonly McpServerDiagnostic[];
readonly mcpError: string | null;
readonly skills: readonly SkillMetadata[] | null;
readonly skillsError: string | null;
}
export function cloneToolInventorySnapshot(snapshot: ToolInventorySnapshot): ToolInventorySnapshot {
return {
...snapshot,
apps: snapshot.apps ? snapshot.apps.map((app) => ({ ...app, pluginDisplayNames: [...app.pluginDisplayNames] })) : null,
plugins: snapshot.plugins
? snapshot.plugins.map((plugin) => ({ ...plugin, details: plugin.details ? { ...plugin.details } : null }))
: null,
pluginMarketplaceErrors: snapshot.pluginMarketplaceErrors.map((error) => ({ ...error })),
mcpServers: snapshot.mcpServers ? snapshot.mcpServers.map((server) => ({ ...server })) : null,
mcpDiagnostics: snapshot.mcpDiagnostics.map((diagnostic) => ({ ...diagnostic })),
skills: snapshot.skills ? snapshot.skills.map((skill) => ({ ...skill })) : null,
};
}

View file

@ -1,31 +1,28 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import {
cloneServerDiagnostics,
diagnosticsWithToolInventory,
diagnosticsWithProbe,
diagnosticProbeError,
diagnosticProbeOk,
mcpServerStatusSummariesFromStatuses,
upsertMcpServerStatusDiagnostics,
upsertMcpServerDiagnostic,
type Diagnostics,
type DiagnosticProbeMethod,
type McpServerStartupStatus,
type McpServerStatusSummary,
} from "../../../../domain/server/diagnostics";
import { readToolInventory } from "../../../../app-server/tool-inventory";
import { readRateLimitMetadataProbe } from "../../../../app-server/query/metadata-probes";
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
import { mcpStatusLines as buildMcpStatusLines } from "../../application/connection/diagnostics-display";
import type { ChatServerActionHost } from "./host";
interface RefreshDiagnosticProbesOptions {
interface RefreshServerDiagnosticsOptions {
appServerMetadataSnapshot?: boolean;
forceResourceProbes?: boolean;
}
interface DiagnosticProbeSnapshot {
method: DiagnosticProbeMethod;
probe: Diagnostics["probes"][DiagnosticProbeMethod];
mcpServerStatuses?: readonly McpServerStatusSummary[];
}
export interface ChatServerDiagnosticsActionsHost extends ChatServerActionHost {
@ -34,30 +31,44 @@ export interface ChatServerDiagnosticsActionsHost extends ChatServerActionHost {
}
export interface ChatServerDiagnosticsActions {
refreshDiagnosticProbes: (options?: RefreshDiagnosticProbesOptions) => Promise<void>;
mcpStatusLines: () => Promise<string[]>;
refreshServerDiagnostics: (options?: RefreshServerDiagnosticsOptions) => Promise<void>;
recordMcpStartupStatus: (name: string, startupStatus: McpServerStartupStatus, message: string | null) => void;
}
export function createChatServerDiagnosticsActions(host: ChatServerDiagnosticsActionsHost): ChatServerDiagnosticsActions {
return {
refreshDiagnosticProbes: async (options) => {
await refreshDiagnosticProbes(host, options);
refreshServerDiagnostics: async (options) => {
await refreshServerDiagnostics(host, options);
},
mcpStatusLines: () => mcpStatusLines(host),
recordMcpStartupStatus: (name, startupStatus, message) => {
recordMcpStartupStatus(host, name, startupStatus, message);
},
};
}
async function refreshDiagnosticProbes(
async function refreshServerDiagnostics(
host: ChatServerDiagnosticsActionsHost,
options: RefreshDiagnosticProbesOptions = {},
options: RefreshServerDiagnosticsOptions = {},
): Promise<boolean> {
const client = host.currentClient();
if (!client) return false;
const initialDiagnostics = currentMetadataDiagnostics(host);
const state = host.stateStore.getState();
const activeThreadId = state.activeThread.id;
const metadataSnapshot = host.appServerMetadataSnapshot();
const cachedSkills =
options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills);
const cachedSkillsProbe =
options.forceResourceProbes === true
? undefined
: (metadataSnapshot?.serverDiagnostics.probes["skills/list"] ?? state.connection.serverDiagnostics.probes["skills/list"]);
const toolInventory = readToolInventory(client, host.vaultPath, {
threadId: activeThreadId,
mcpDiagnostics: initialDiagnostics.mcpServers,
...(cachedSkills !== undefined ? { cachedSkills } : {}),
...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}),
});
const probes: Promise<DiagnosticProbeSnapshot>[] = [];
if (options.forceResourceProbes === true && options.appServerMetadataSnapshot !== true) {
probes.push(
@ -66,66 +77,24 @@ async function refreshDiagnosticProbes(
() => client.listModels(false),
(response) => `${String(response.data.length)} models`,
),
probeDiagnostic(
"skills/list",
() => client.listSkills(host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return `${String(count)} skills`;
},
),
readRateLimitDiagnosticProbe(client),
);
}
probes.push(
probeDiagnostic(
"hooks/list",
() => client.listHooks(host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
return `${String(count)} hooks`;
},
),
probeDiagnostic(
"mcpServerStatus/list",
() => client.listMcpServerStatus(mcpServerStatusParams(host.stateStore.getState().activeThread.id)),
(response) => {
const summaries = mcpServerStatusSummariesFromStatuses(response.data);
const issueCount = summaries.filter((server) => server.authStatus === "notLoggedIn").length;
return issueCount > 0
? `${String(summaries.length)} servers, ${String(issueCount)} auth issues`
: `${String(summaries.length)} servers`;
},
(response) => mcpServerStatusSummariesFromStatuses(response.data),
),
probeDiagnostic(
"collaborationMode/list",
() => client.listCollaborationModes(),
(response) => `${String(response.data.length)} modes`,
),
probeDiagnostic(
"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",
),
);
const results = await Promise.all(probes);
const [results, toolInventoryResult] = await Promise.all([Promise.all(probes), toolInventory]);
if (host.currentClient() !== client) return false;
let diagnostics = currentMetadataDiagnostics(host);
for (const result of results) {
diagnostics = diagnosticsWithProbe(diagnostics, result.probe);
if (result.mcpServerStatuses) diagnostics = upsertMcpServerStatusDiagnostics(diagnostics, result.mcpServerStatuses);
}
for (const probe of toolInventoryResult.probes) {
diagnostics = diagnosticsWithProbe(diagnostics, probe);
}
if (toolInventoryResult.mcpServerStatuses) {
diagnostics = upsertMcpServerStatusDiagnostics(diagnostics, toolInventoryResult.mcpServerStatuses);
}
diagnostics = diagnosticsWithToolInventory(diagnostics, toolInventoryResult.inventory);
host.updateAppServerMetadata((metadata) => (metadata ? { ...metadata, serverDiagnostics: diagnostics } : null));
host.stateStore.dispatch({ type: "connection/metadata-applied", serverDiagnostics: diagnostics });
return true;
@ -133,21 +102,7 @@ async function refreshDiagnosticProbes(
async function readRateLimitDiagnosticProbe(client: AppServerClient): Promise<DiagnosticProbeSnapshot> {
const result = await readRateLimitMetadataProbe(client);
return { method: "account/rateLimits/read", probe: result.probe };
}
async function mcpStatusLines(host: ChatServerDiagnosticsActionsHost): Promise<string[]> {
const client = host.currentClient();
if (!client) return ["MCP servers", "Codex app-server is not connected."];
try {
const state = host.stateStore.getState();
const response = await client.listMcpServerStatus(mcpServerStatusParams(state.activeThread.id));
return buildMcpStatusLines(mcpServerStatusSummariesFromStatuses(response.data), state.connection.serverDiagnostics.mcpServers);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return ["MCP servers", `Could not load MCP servers: ${message}`];
}
return { probe: result.probe };
}
function recordMcpStartupStatus(
@ -180,25 +135,13 @@ async function probeDiagnostic<T>(
method: DiagnosticProbeMethod,
request: () => Promise<T>,
summarize: (response: T) => string | null,
mcpServerStatuses?: (response: T) => readonly McpServerStatusSummary[],
): Promise<DiagnosticProbeSnapshot> {
try {
const response = await request();
const statuses = mcpServerStatuses?.(response);
return {
method,
probe: diagnosticProbeOk(method, summarize(response), Date.now()),
...(statuses ? { mcpServerStatuses: statuses } : {}),
};
} catch (error) {
return { method, probe: diagnosticProbeError(method, error, Date.now()) };
return { probe: diagnosticProbeError(method, error, Date.now()) };
}
}
function mcpServerStatusParams(threadId: string | null): Parameters<AppServerClient["listMcpServerStatus"]>[0] {
return {
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
};
}

View file

@ -137,11 +137,11 @@ export const SLASH_COMMANDS = [
detail: "Show Codex CLI and Codex App Server diagnostics.",
},
{
command: "/mcp",
usage: "/mcp",
command: "/tools",
usage: "/tools",
argsKind: "none",
surface: "diagnostic",
detail: "Show MCP servers reported by Codex App Server.",
detail: "Show Codex plugins, tool providers, and skills reported by App Server.",
},
{
command: "/model",
@ -172,6 +172,11 @@ export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : ne
export type SlashCommandDefinition = (typeof SLASH_COMMANDS)[number];
export interface SlashCommandHelpSection {
readonly title: string;
readonly auditFacts: readonly { key: string; value: string }[];
}
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
const definition = SLASH_COMMANDS.find((item) => item.command === `/${command}`);
if (!definition) throw new Error(`Unknown slash command: ${command}`);
@ -187,11 +192,23 @@ export function slashCommandSubcommandDefinition(command: SlashCommandName, subc
return slashCommandSubcommands(command).find((item) => item.subcommand === subcommand) ?? null;
}
export function slashCommandHelpSections(): { title: string; rows: { key: string; value: string }[] }[] {
export function slashCommandHelpSections(): SlashCommandHelpSection[] {
return (Object.keys(SLASH_COMMAND_SURFACE_LABELS) as SlashCommandSurface[])
.map((surface) => ({
title: SLASH_COMMAND_SURFACE_LABELS[surface],
rows: SLASH_COMMANDS.filter((item) => item.surface === surface).map((item) => ({ key: item.usage, value: item.detail })),
auditFacts: SLASH_COMMANDS.filter((item) => item.surface === surface).flatMap(slashCommandHelpRows),
}))
.filter((section) => section.rows.length > 0);
.filter((section) => section.auditFacts.length > 0);
}
function slashCommandHelpRows(command: SlashCommandDefinition): readonly { key: string; value: string }[] {
if (!("subcommands" in command)) return [{ key: command.usage, value: command.detail }];
return [
{ key: command.command, value: command.detail },
...command.subcommands.map((subcommand) => ({
key: subcommand.usage,
value: subcommand.detail,
})),
];
}

View file

@ -23,7 +23,7 @@ export interface ChatConnectionMetadataActions {
}
export interface ChatConnectionDiagnosticsActions {
refreshDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
}
export interface ChatConnectionControllerHost {
@ -124,7 +124,7 @@ async function refreshDiagnostics(
if (!host.connection.currentClient()) return;
host.clearDeferredDiagnostics();
await host.metadata.refreshAppServerMetadata();
await host.diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
await host.diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
}
async function refreshStatusPanel(

View file

@ -1,15 +1,12 @@
import { DIAGNOSTIC_PROBE_METHODS, serverIdentity, serverPlatform } from "../../../../domain/server/diagnostics";
import { serverIdentity, serverPlatform, type DiagnosticProbeMethod } from "../../../../domain/server/diagnostics";
import { CLIENT_VERSION } from "../../../../constants";
import type { ChatState } from "../state/root-reducer";
import type { ServerInitialization } from "../../../../domain/server/initialization";
import type {
Diagnostics,
DiagnosticProbeResult,
McpServerDiagnostic,
McpServerStatusSummary,
} from "../../../../domain/server/diagnostics";
import type { Diagnostics, DiagnosticProbeResult } from "../../../../domain/server/diagnostics";
interface DiagnosticRow {
const RUNTIME_CHECK_PROBE_METHODS: readonly DiagnosticProbeMethod[] = ["model/list", "account/rateLimits/read"];
export interface DiagnosticRow {
label: string;
value: string;
level?: "normal" | "warning" | "error";
@ -33,7 +30,7 @@ export interface ConnectionDiagnosticsModelInput {
configuredCommand: string;
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): DiagnosticSection[] {
export function connectionDiagnosticSectionsModel(input: ConnectionDiagnosticsModelInput): DiagnosticSection[] {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
@ -43,7 +40,6 @@ export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInpu
}
export function connectionDiagnosticSections(input: ConnectionDiagnosticsInput): DiagnosticSection[] {
const mcpRows = mcpServerDiagnosticRows(input.diagnostics.mcpServers);
return [
{
title: "Process",
@ -57,12 +53,8 @@ export function connectionDiagnosticSections(input: ConnectionDiagnosticsInput):
],
},
{
title: "App Server Checks",
rows: DIAGNOSTIC_PROBE_METHODS.map((method) => diagnosticProbeRow(input.diagnostics.probes[method])),
},
{
title: "MCP issues",
rows: mcpRows.length > 0 ? mcpRows : [{ label: "issues", value: "(none)" }],
title: "Runtime Checks",
rows: RUNTIME_CHECK_PROBE_METHODS.map((method) => diagnosticProbeRow(input.diagnostics.probes[method])),
},
];
}
@ -76,71 +68,8 @@ function diagnosticProbeRow(probe: DiagnosticProbeResult): DiagnosticRow {
};
}
function mcpServerDiagnosticRows(servers: readonly McpServerDiagnostic[]): DiagnosticRow[] {
return servers.filter(isMcpServerIssue).map((server) => ({
label: `mcp ${server.name}`,
value: mcpServerDiagnosticValue(server),
level: server.startupStatus === "failed" ? "error" : "warning",
}));
}
function diagnosticProbeLevel(status: DiagnosticProbeResult["status"]): NonNullable<DiagnosticRow["level"]> {
if (status === "failed") return "error";
if (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(" - ");
}
export function mcpStatusLines(servers: readonly McpServerStatusSummary[], diagnostics: readonly McpServerDiagnostic[] = []): string[] {
if (servers.length === 0 && diagnostics.length === 0) {
return ["MCP servers", "Codex App Server reports no MCP servers."];
}
const statusByName = new Map(servers.map((server) => [server.name, server]));
const diagnosticByName = new Map(diagnostics.map((diagnostic) => [diagnostic.name, diagnostic]));
const names = new Set([...statusByName.keys(), ...diagnosticByName.keys()]);
const rows = [...names]
.sort((a, b) => a.localeCompare(b))
.map((name) => {
const server = statusByName.get(name);
const diagnostic = diagnosticByName.get(name);
return server ? mcpServerStatusLine(server, diagnostic) : mcpDiagnosticOnlyLine(name, diagnostic);
});
return ["MCP servers", ...rows];
}
function mcpServerStatusLine(server: McpServerStatusSummary, diagnostic: McpServerDiagnostic | undefined): string {
const startup = diagnostic?.startupStatus && diagnostic.startupStatus !== "unknown" ? diagnostic.startupStatus : "available";
const tools = server.toolCount;
const resources = server.resourceCount;
const templates = server.resourceTemplateCount;
const parts = [startup, `auth ${server.authStatus}`, countLabel(tools, "tool"), countLabel(resources, "resource")];
if (templates > 0) parts.push(countLabel(templates, "resource template"));
if (diagnostic?.message) parts.push(diagnostic.message);
return `${server.name}: ${parts.join(", ")}`;
}
function mcpDiagnosticOnlyLine(name: string, diagnostic: McpServerDiagnostic | undefined): string {
const startup = diagnostic?.startupStatus ?? "unknown";
const auth = diagnostic?.authStatus ? `auth ${diagnostic.authStatus}` : "auth unknown";
const tools =
diagnostic?.toolCount === null || diagnostic?.toolCount === undefined ? "tools unknown" : countLabel(diagnostic.toolCount, "tool");
const parts = [startup, auth, tools];
if (diagnostic?.message) parts.push(diagnostic.message);
return `${name}: ${parts.join(", ")}`;
}
function countLabel(count: number, singular: string): string {
return `${String(count)} ${singular}${count === 1 ? "" : "s"}`;
}

View file

@ -0,0 +1,264 @@
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
import type { Diagnostics, McpServerDiagnostic, McpServerStatusSummary } from "../../../../domain/server/diagnostics";
import type { ToolInventoryApp, ToolInventoryPlugin, ToolInventorySnapshot } from "../../../../domain/server/tool-inventory";
import type { DiagnosticRow, DiagnosticSection } from "./diagnostics-display";
const PERSONAL_SKILLS_LABEL = "Personal";
const SYSTEM_SKILLS_LABEL = "System";
const WORKSPACE_SKILLS_FALLBACK_LABEL = "Workspace";
const CODEX_APPS_PROVIDER_LABEL = "codex_apps";
const SKILL_PROVENANCE_RANKS = {
workspace: 0,
personal: 1,
system: 2,
plugin: 3,
} as const;
type SkillProvenanceRank = (typeof SKILL_PROVENANCE_RANKS)[keyof typeof SKILL_PROVENANCE_RANKS];
interface SkillProvenance {
label: string;
rank: SkillProvenanceRank;
}
export function toolInventorySections(inventory: ToolInventorySnapshot | null): DiagnosticSection[] {
if (!inventory) {
const row = { label: "Codex capabilities", value: "not loaded", level: "warning" as const };
return [{ title: "Tool providers", rows: [row] }];
}
return toolInventorySnapshotSections(inventory);
}
export function toolInventoryDiagnosticSections(diagnostics: Pick<Diagnostics, "toolInventory" | "mcpServers">): DiagnosticSection[] {
if (!diagnostics.toolInventory) return toolInventorySections(null);
return toolInventorySnapshotSections({
...diagnostics.toolInventory,
mcpDiagnostics: diagnostics.mcpServers,
});
}
function toolInventorySnapshotSections(inventory: ToolInventorySnapshot): DiagnosticSection[] {
return [
{ title: "Plugins", rows: pluginRows(inventory) },
{ title: "Tool providers", rows: toolProviderRows(inventory) },
{ title: "Skills", rows: skillRows(inventory) },
];
}
function pluginRows(inventory: ToolInventorySnapshot): DiagnosticRow[] {
if (inventory.pluginsError) return [{ label: "Plugins", value: inventory.pluginsError, level: "error" }];
if (!inventory.plugins) return [{ label: "Plugins", value: "not loaded", level: "warning" }];
const rows = inventory.plugins.filter(isUsablePlugin).map(pluginRow);
return rows.length > 0 ? rows : [{ label: "Plugins", value: "(none)" }];
}
function pluginRow(plugin: ToolInventoryPlugin): DiagnosticRow {
return {
label: plugin.displayName ?? plugin.name,
value: pluginBundleSummary(plugin),
level: plugin.detailsError ? "warning" : "normal",
};
}
function toolProviderRows(inventory: ToolInventorySnapshot): DiagnosticRow[] {
const rows = mcpToolProviderRows(inventory).sort((left, right) => left.label.localeCompare(right.label));
return [codexAppsProviderRow(inventory), ...rows];
}
function codexAppsProviderRow(inventory: ToolInventorySnapshot): DiagnosticRow {
if (inventory.appsError) return { label: CODEX_APPS_PROVIDER_LABEL, value: inventory.appsError, level: "error" };
if (!inventory.apps) return { label: CODEX_APPS_PROVIDER_LABEL, value: "not loaded", level: "warning" };
return { label: CODEX_APPS_PROVIDER_LABEL, value: listSummary(inventory.apps.filter(isUsableApp).map((app) => app.name)) };
}
function mcpToolProviderRows(inventory: ToolInventorySnapshot): DiagnosticRow[] {
if (inventory.mcpError) return [{ label: "MCP servers", value: inventory.mcpError, level: "error" }];
if (inventory.mcpServers === null && inventory.mcpDiagnostics.length === 0) {
return [{ label: "MCP servers", value: "not loaded", level: "warning" }];
}
const statusByName = new Map((inventory.mcpServers ?? []).map((server) => [server.name, server]));
const diagnosticByName = new Map(inventory.mcpDiagnostics.map((diagnostic) => [diagnostic.name, diagnostic]));
const names = new Set([...statusByName.keys(), ...diagnosticByName.keys()]);
return [...names].flatMap((name) => {
if (name === CODEX_APPS_PROVIDER_LABEL) return [];
const server = statusByName.get(name);
const diagnostic = diagnosticByName.get(name);
return [server ? mcpToolProviderStatusRow(server, diagnostic) : mcpToolProviderDiagnosticRow(name, diagnostic)];
});
}
function mcpToolProviderStatusRow(server: McpServerStatusSummary, diagnostic: McpServerDiagnostic | undefined): DiagnosticRow {
const startup = diagnostic?.startupStatus && diagnostic.startupStatus !== "unknown" ? diagnostic.startupStatus : "available";
const parts = [
"MCP server",
startup,
`auth ${server.authStatus}`,
countLabel(server.toolCount, "tool"),
countLabel(server.resourceCount, "resource"),
];
if (server.resourceTemplateCount > 0) parts.push(countLabel(server.resourceTemplateCount, "resource template"));
if (diagnostic?.message) parts.push(diagnostic.message);
return {
label: server.name,
value: parts.join(", "),
level: mcpToolProviderLevel(diagnostic, server.authStatus),
};
}
function mcpToolProviderDiagnosticRow(name: string, diagnostic: McpServerDiagnostic | undefined): DiagnosticRow {
const startup = diagnostic?.startupStatus ?? "unknown";
const auth = diagnostic?.authStatus ? `auth ${diagnostic.authStatus}` : "auth unknown";
const tools =
diagnostic?.toolCount === null || diagnostic?.toolCount === undefined ? "tools unknown" : countLabel(diagnostic.toolCount, "tool");
const parts = ["MCP server", startup, auth, tools];
if (diagnostic?.message) parts.push(diagnostic.message);
return {
label: name,
value: parts.join(", "),
level: mcpToolProviderLevel(diagnostic, diagnostic?.authStatus ?? null),
};
}
function mcpToolProviderLevel(
diagnostic: McpServerDiagnostic | undefined,
authStatus: McpServerDiagnostic["authStatus"] | McpServerStatusSummary["authStatus"],
): NonNullable<DiagnosticRow["level"]> {
if (diagnostic?.startupStatus === "failed") return "error";
if (authStatus === "notLoggedIn" || diagnostic?.startupStatus === "cancelled") return "warning";
return "normal";
}
function skillRows(inventory: ToolInventorySnapshot): DiagnosticRow[] {
if (inventory.skillsError) return [{ label: "Skills", value: inventory.skillsError, level: "error" }];
if (!inventory.skills) return [{ label: "Skills", value: "not loaded", level: "warning" }];
const skillsByProvenance = new Map<string, Set<string>>();
const provenanceRanks = new Map<string, SkillProvenanceRank>();
for (const skill of inventory.skills) {
if (!skill.enabled) continue;
const provenance = skillProvenance(skill);
const skills = skillsByProvenance.get(provenance.label) ?? new Set<string>();
skills.add(skillDisplayName(skill));
skillsByProvenance.set(provenance.label, skills);
provenanceRanks.set(provenance.label, provenance.rank);
}
if (skillsByProvenance.size === 0) return [{ label: "Skills", value: "(none)" }];
return [...skillsByProvenance.entries()]
.sort(([left], [right]) => compareSkillProvenance(left, right, provenanceRanks))
.map(([provenance, skills]) => ({ label: provenance, value: listSummary([...skills]) }));
}
function isUsableApp(app: ToolInventoryApp): boolean {
return app.enabled && app.accessible;
}
function isUsablePlugin(plugin: ToolInventoryPlugin): boolean {
return plugin.enabled && plugin.installed;
}
function pluginBundleSummary(plugin: ToolInventoryPlugin): string {
if (plugin.detailsError) return "details unavailable";
if (!plugin.details) return "details not loaded";
const parts = [
countPart(plugin.details.skillCount, "skill"),
countPart(plugin.details.hookCount, "hook"),
countPart(plugin.details.appCount, "app"),
countPart(plugin.details.mcpServerCount, "MCP server"),
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(", ") : "no listed items";
}
function countPart(count: number, singular: string): string | null {
if (count === 0) return null;
return `${String(count)} ${singular}${count === 1 ? "" : "s"}`;
}
function countLabel(count: number, singular: string): string {
return `${String(count)} ${singular}${count === 1 ? "" : "s"}`;
}
function skillDisplayName(skill: SkillMetadata): string {
const prefixSeparator = skill.name.indexOf(":");
if (prefixSeparator > 0 && prefixSeparator < skill.name.length - 1) return skill.name.slice(prefixSeparator + 1);
return skill.name;
}
function skillProvenance(skill: SkillMetadata): SkillProvenance {
const prefixSeparator = skill.name.indexOf(":");
if (prefixSeparator > 0) return { label: pluginLabel(skill.name.slice(0, prefixSeparator)), rank: SKILL_PROVENANCE_RANKS.plugin };
const path = normalizedPath(skill.path);
if (path.includes("/.codex/skills/.system/")) return { label: SYSTEM_SKILLS_LABEL, rank: SKILL_PROVENANCE_RANKS.system };
const pluginCacheMarker = "/plugins/cache/";
const pluginCacheIndex = path.indexOf(pluginCacheMarker);
if (pluginCacheIndex >= 0) {
const pluginCachePath = path.slice(pluginCacheIndex + pluginCacheMarker.length);
const parts = pluginCachePath.split("/").filter(Boolean);
return { label: pluginLabel(parts[1] ?? parts[0] ?? "plugin"), rank: SKILL_PROVENANCE_RANKS.plugin };
}
const workspaceRoot = skillWorkspaceRoot(path);
if (!workspaceRoot) return { label: WORKSPACE_SKILLS_FALLBACK_LABEL, rank: SKILL_PROVENANCE_RANKS.workspace };
if (isPersonalSkillRoot(workspaceRoot)) return { label: PERSONAL_SKILLS_LABEL, rank: SKILL_PROVENANCE_RANKS.personal };
return { label: basename(workspaceRoot) || WORKSPACE_SKILLS_FALLBACK_LABEL, rank: SKILL_PROVENANCE_RANKS.workspace };
}
function listSummary(names: readonly string[]): string {
const sortedNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
return sortedNames.length > 0 ? sortedNames.join(", ") : "(none)";
}
function compareSkillProvenance(left: string, right: string, ranks: ReadonlyMap<string, SkillProvenanceRank>): number {
const leftRank = ranks.get(left) ?? SKILL_PROVENANCE_RANKS.plugin;
const rightRank = ranks.get(right) ?? SKILL_PROVENANCE_RANKS.plugin;
return leftRank - rightRank || left.localeCompare(right);
}
function normalizedPath(path: string): string {
return path.replace(/\\/g, "/");
}
function skillWorkspaceRoot(path: string): string | null {
for (const marker of ["/.codex/skills/", "/.agents/skills/"]) {
const markerIndex = path.indexOf(marker);
if (markerIndex >= 0) return path.slice(0, markerIndex);
}
return null;
}
function isPersonalSkillRoot(root: string): boolean {
const parts = root.split("/").filter(Boolean);
if (parts.length === 1 && parts[0] === "root") return true;
if (parts.length === 2 && (parts[0] === "Users" || parts[0] === "home")) return true;
if (parts.length === 3 && parts[1] === "Users") return true;
return false;
}
function basename(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts.at(-1) ?? "";
}
function pluginLabel(name: string): string {
const canonicalLabels: Record<string, string> = {
browser: "Browser",
github: "GitHub",
gmail: "Gmail",
"google-drive": "Google Drive",
pdf: "PDF",
slack: "Slack",
};
return canonicalLabels[name] ?? name.split("-").filter(Boolean).map(capitalize).join(" ");
}
function capitalize(value: string): string {
return value.length > 0 ? `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}` : value;
}

View file

@ -31,7 +31,7 @@ export interface ConversationTurnActionsContext {
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
mcpStatusLines: () => Promise<string[]>;
toolInventoryDetails: () => MessageStreamNoticeSection[];
};
thread: {
ensureRestoredThreadLoaded: () => Promise<boolean>;
@ -115,7 +115,7 @@ export function createConversationTurnActions(
setStatus: status.set,
statusSummaryLines: runtime.statusSummaryLines,
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
mcpStatusLines: runtime.mcpStatusLines,
toolInventoryDetails: runtime.toolInventoryDetails,
modelStatusLines: runtime.modelStatusLines,
effortStatusLines: runtime.effortStatusLines,
};

View file

@ -51,7 +51,7 @@ export interface SlashCommandExecutionPorts {
};
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
mcpStatusLines: () => Promise<string[]>;
toolInventoryDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
}
@ -227,8 +227,8 @@ export async function executeSlashCommand(
case "doctor":
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;
case "mcp":
context.addStructuredSystemMessage("MCP servers", detailsFromLines(await context.mcpStatusLines()));
case "tools":
context.addStructuredSystemMessage("Codex capabilities", context.toolInventoryDetails());
return;
case "model": {
const requested = parseModelOverride(args);

View file

@ -213,13 +213,13 @@ export function createConnectionBundle(
refreshSkills: (forceReload) => serverMetadata.refreshSkills(forceReload),
},
diagnostics: {
refreshDiagnosticProbes: (options) => serverDiagnostics.refreshDiagnosticProbes(options),
refreshServerDiagnostics: (options) => serverDiagnostics.refreshServerDiagnostics(options),
},
refreshSharedThreads,
scheduleDeferredDiagnostics: () => {
host.deferredTasks.scheduleDiagnostics(() => {
if (connection.isConnected()) {
void serverDiagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
void serverDiagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
}
});
},

View file

@ -60,7 +60,8 @@ import {
modelStatusLines as buildModelStatusLines,
statusSummaryLines as buildStatusSummaryLines,
} from "../presentation/runtime/status";
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
import { connectionDiagnosticSectionsModel } from "../application/connection/diagnostics-display";
import { toolInventoryDiagnosticSections } from "../application/connection/tool-inventory-display";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id";
import type { RuntimeSnapshot } from "../application/runtime/snapshot";
@ -162,6 +163,7 @@ interface ChatPanelRuntimeProjection {
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
toolInventoryDetails: () => MessageStreamNoticeSection[];
}
interface ChatPanelComposerAndTurnInput {
@ -177,7 +179,6 @@ interface ChatPanelComposerAndTurnInput {
composerController: ChatComposerController;
runtimeSettings: ChatPanelRuntimeSettingsActions;
serverThreads: ChatServerThreadActions;
serverDiagnostics: ChatServerDiagnosticsActions;
goals: ChatPanelGoalActions;
autoTitleCoordinator: AutoTitleCoordinator;
invalidateThreadWork: () => void;
@ -253,7 +254,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
inboundHandler,
} = connectionBundle;
const connectionController = controller;
const { threads: serverThreads, diagnostics: serverDiagnostics } = connectionBundle.serverActions;
const { threads: serverThreads } = connectionBundle.serverActions;
const ensureConnected = () => connectionController.ensureConnected();
const refreshActiveThreads = () => connectionController.refreshActiveThreads();
const threadOperations = createSessionThreadOperations(environment, currentClient);
@ -305,7 +306,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
composerController,
runtimeSettings,
serverThreads,
serverDiagnostics,
goals,
autoTitleCoordinator,
invalidateThreadWork,
@ -786,7 +786,6 @@ function createComposerAndTurnActions(
composerController,
runtimeSettings,
serverThreads,
serverDiagnostics,
goals,
autoTitleCoordinator,
invalidateThreadWork,
@ -840,7 +839,7 @@ function createComposerAndTurnActions(
modelStatusLines: runtimeProjection.modelStatusLines,
effortStatusLines: runtimeProjection.effortStatusLines,
statusSummaryLines: runtimeProjection.statusSummaryLines,
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
toolInventoryDetails: runtimeProjection.toolInventoryDetails,
},
thread: {
ensureRestoredThreadLoaded: () =>
@ -998,6 +997,7 @@ function createSessionRuntimeProjection(host: ChatPanelSessionGraphHost, connect
modelStatusLines: () => modelStatusLines(host),
effortStatusLines: () => effortStatusLines(host),
statusSummaryLines: () => statusSummaryLines(host),
toolInventoryDetails: () => toolInventoryDetails(host),
};
}
@ -1070,11 +1070,20 @@ function effortStatusLines(host: ChatPanelSessionGraphHost): string[] {
}
function connectionDiagnosticDetails(host: ChatPanelSessionGraphHost, connection: ConnectionManager): MessageStreamNoticeSection[] {
return connectionDiagnosticsModel({
const sections = connectionDiagnosticSectionsModel({
state: host.stateStore.getState(),
connected: connection.isConnected(),
configuredCommand: host.environment.plugin.settingsRef.settings.codexPath,
}).map((section) => ({
});
return sections.map((section) => ({
title: section.title,
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));
}
function toolInventoryDetails(host: ChatPanelSessionGraphHost): MessageStreamNoticeSection[] {
const sections = toolInventoryDiagnosticSections(host.stateStore.getState().connection.serverDiagnostics);
return sections.map((section) => ({
title: section.title,
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));

View file

@ -4,7 +4,8 @@ import { h } from "preact";
import type { Thread } from "../../../../domain/threads/model";
import { getThreadTitle } from "../../../../domain/threads/model";
import { rateLimitSummary } from "../../presentation/runtime/status";
import { connectionDiagnosticsModel } from "../../application/connection/diagnostics-display";
import { connectionDiagnosticSectionsModel } from "../../application/connection/diagnostics-display";
import { toolInventoryDiagnosticSections } from "../../application/connection/tool-inventory-display";
import type { RuntimeSnapshot } from "../../application/runtime/snapshot";
import { chatTurnBusy, type ChatState } from "../../application/state/root-reducer";
import { toolbarStateFromShellState, useChatPanelShellState, type ChatPanelToolbarShellState } from "../shell-state";
@ -75,11 +76,12 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
openPanel: projection.openPanel,
threads: projection.threads,
connectLabel: input.connected ? "Reconnect" : "Connect",
diagnostics: connectionDiagnosticsModel({
diagnostics: connectionDiagnosticSectionsModel({
state,
connected: input.connected,
configuredCommand: input.configuredCommand,
}),
toolInventory: toolInventoryDiagnosticSections(state.connection.serverDiagnostics),
};
}

View file

@ -6,6 +6,7 @@ import type { GoalActions } from "../application/threads/goal-actions";
import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions";
import type { SelectionActions } from "../application/threads/selection-actions";
import type { ToolbarActions } from "../ui/toolbar";
import { copyTextWithNotice } from "../../../shared/ui/clipboard";
export interface ToolbarPanelActionsHost {
stateStore: ChatStateStore;
@ -149,6 +150,9 @@ export function createChatPanelToolbarActions(host: ChatPanelToolbarActionsHost,
refreshStatus: () => {
void deps.connectionController.refreshStatusPanel();
},
copyDebugDetails: (details) => {
void copyTextWithNotice(details, "Copied debug details.", "Could not copy debug details.");
},
resumeThread: (threadId) => {
void deps.selection.selectThreadFromToolbar(threadId);
},

View file

@ -43,6 +43,7 @@ export interface ToolbarViewModel {
threads: ToolbarThreadRow[];
connectLabel: string;
diagnostics: ToolbarDiagnosticSection[];
toolInventory: ToolbarDiagnosticSection[];
}
export interface ToolbarActions {
@ -54,6 +55,7 @@ export interface ToolbarActions {
toggleStatusPanel: () => void;
connect: () => void;
refreshStatus: () => void;
copyDebugDetails: (details: string) => void;
resumeThread: (threadId: string) => void;
startArchiveThread: (threadId: string) => void;
archiveThread: (threadId: string, saveMarkdown: boolean) => void;
@ -161,16 +163,19 @@ function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: Too
<>
<div className="codex-panel__status-panel-items" role="menu">
<ToolbarPanelItem label={model.connectLabel} onClick={actions.connect} className="codex-panel__status-panel-item" role="menuitem" />
<ToolbarPanelItem label="Refresh" onClick={actions.refreshStatus} className="codex-panel__status-panel-item" role="menuitem" />
<ToolbarPanelItem
label="Refresh status"
onClick={actions.refreshStatus}
label="Copy debug details"
onClick={() => {
actions.copyDebugDetails(model.debugDetails);
}}
className="codex-panel__status-panel-item"
role="menuitem"
/>
</div>
<RateLimitPanel rateLimit={model.rateLimit} />
<ConnectionDiagnostics sections={model.diagnostics} />
<DebugDetails details={model.debugDetails} />
<DiagnosticSectionsPanel title="Connection" sections={model.diagnostics} />
<DiagnosticSectionsPanel title="Codex capabilities" sections={model.toolInventory} />
</>
);
}
@ -213,10 +218,10 @@ function RateLimitPanel({ rateLimit }: { rateLimit: RateLimitSummary | null }):
);
}
function ConnectionDiagnostics({ sections }: { sections: ToolbarDiagnosticSection[] }): UiNode {
function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ToolbarDiagnosticSection[] }): UiNode {
return (
<div className="codex-panel__connection-diagnostics">
<div className="codex-panel__connection-diagnostics-title">Connection</div>
<div className="codex-panel__connection-diagnostics-title">{title}</div>
{sections.map((section) => (
<DiagnosticSection key={section.title} section={section} />
))}
@ -224,31 +229,31 @@ function ConnectionDiagnostics({ sections }: { sections: ToolbarDiagnosticSectio
);
}
function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode {
return (
<dl className="codex-panel__connection-diagnostics-list">
{rows.map((row) => (
<DiagnosticRow key={`${row.label}:${row.value}:${row.level ?? "normal"}`} row={row} />
))}
</dl>
);
}
function DiagnosticSection({ section }: { section: ToolbarDiagnosticSection }): UiNode {
return (
<>
<div className="codex-panel__connection-diagnostics-section">{section.title}</div>
<dl className="codex-panel__connection-diagnostics-list">
{section.rows.map((row) => (
<div
key={`${row.label}:${row.value}:${row.level ?? "normal"}`}
className={`codex-panel__connection-diagnostics-row codex-panel__connection-diagnostics-row--${row.level ?? "normal"}`}
>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
<DiagnosticRows rows={section.rows} />
</>
);
}
function DebugDetails({ details }: { details: string }): UiNode {
function DiagnosticRow({ row }: { row: ToolbarDiagnosticRow }): UiNode {
return (
<details className="codex-panel__debug-details">
<summary className="codex-panel__debug-details-summary">Debug details</summary>
<pre className="codex-panel__debug-details-content">{details}</pre>
</details>
<div className={`codex-panel__connection-diagnostics-row codex-panel__connection-diagnostics-row--${row.level ?? "normal"}`}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
);
}

View file

@ -28,36 +28,6 @@
display: none;
}
.codex-panel__debug-details {
margin-top: var(--codex-panel-panel-gap);
padding: var(--codex-panel-item-gap) 0 0;
color: var(--codex-panel-text-muted);
}
.codex-panel__debug-details-summary {
padding: var(--codex-panel-section-label-padding);
color: var(--codex-panel-section-label-color);
font-size: var(--codex-panel-section-label-size);
font-weight: var(--codex-panel-section-label-weight);
line-height: var(--codex-panel-section-label-line-height);
cursor: default;
}
.codex-panel__debug-details-summary:hover {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel__debug-details-content {
margin: 0 var(--codex-panel-section-gap) var(--codex-panel-control-gap);
overflow: auto;
color: var(--codex-panel-text-muted);
font-size: var(--font-ui-smaller);
line-height: var(--line-height-tight);
white-space: pre;
cursor: text;
user-select: text;
}
.codex-panel__approval-details summary,
.codex-panel__output summary,
.codex-panel-diff-file summary,

View file

@ -45,14 +45,12 @@ describe("app-server diagnostics", () => {
summary: "3 skills",
checkedAt: 123,
});
expect(diagnosticProbeError("hooks/list", new Error("boom"), 456)).toMatchObject({
method: "hooks/list",
expect(diagnosticProbeError("plugin/installed", new Error("boom"), 456)).toMatchObject({
method: "plugin/installed",
status: "failed",
message: "boom",
checkedAt: 456,
});
expect(diagnosticProbeError("modelProvider/capabilities/read", new Error("unknown method"), 790).status).toBe("failed");
expect(diagnosticProbeError("modelProvider/capabilities/read", new Error("unsupported RPC method"), 791).status).toBe("failed");
expect(diagnosticProbeError("model/list", new Error("unknown provider failure"), 792).status).toBe("failed");
});

View file

@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import { readToolInventory } from "../../src/app-server/tool-inventory";
describe("tool inventory", () => {
it("reads plugin details with exactly one marketplace locator", async () => {
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => ({
plugin: {
skills: params.pluginName === "local-plugin" ? [{ name: "local-skill" }] : [],
hooks: [],
apps: [],
appTemplates: [],
mcpServers: params.pluginName === "remote-plugin" ? ["remote-server"] : [],
},
}));
const client = {
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
listInstalledPlugins: vi.fn().mockResolvedValue({
marketplaces: [
{
name: "local-marketplace",
path: "/marketplaces/local.json",
plugins: [
{
id: "local-plugin@local-marketplace",
name: "local-plugin",
interface: { displayName: "Local Plugin" },
localVersion: "1.0.0",
installed: true,
enabled: true,
availability: "AVAILABLE",
source: { type: "local", path: "/plugins/local-plugin" },
},
],
},
{
name: "remote-marketplace",
path: null,
plugins: [
{
id: "remote-plugin@remote-marketplace",
name: "remote-plugin",
interface: { displayName: "Remote Plugin" },
localVersion: "2.0.0",
installed: true,
enabled: true,
availability: "AVAILABLE",
source: { type: "remote" },
},
],
},
],
marketplaceLoadErrors: [],
}),
readPlugin,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
} as unknown as AppServerClient;
const result = await readToolInventory(client, "/vault");
expect(readPlugin).toHaveBeenCalledWith({
pluginName: "local-plugin",
marketplacePath: "/marketplaces/local.json",
});
expect(readPlugin).toHaveBeenCalledWith({
pluginName: "remote-plugin",
remoteMarketplaceName: "remote-marketplace",
});
expect(readPlugin).not.toHaveBeenCalledWith(
expect.objectContaining({ marketplacePath: expect.any(String), remoteMarketplaceName: expect.any(String) }),
);
expect(result.inventory.plugins?.map((plugin) => [plugin.name, plugin.details, plugin.detailsError])).toEqual([
["local-plugin", { skillCount: 1, hookCount: 0, appCount: 0, mcpServerCount: 0 }, null],
["remote-plugin", { skillCount: 0, hookCount: 0, appCount: 0, mcpServerCount: 1 }, null],
]);
});
});

View file

@ -26,14 +26,14 @@ function createController({ connected = false, client = {} as AppServerClient }
isConnected: () => Boolean(currentClient),
};
const refreshAppServerMetadata = vi.fn().mockResolvedValue(null);
const refreshDiagnosticProbes = vi.fn().mockResolvedValue(undefined);
const refreshServerDiagnostics = vi.fn().mockResolvedValue(undefined);
const refreshSkills = vi.fn().mockResolvedValue(undefined);
const metadata = {
refreshAppServerMetadata,
refreshSkills,
} satisfies ChatConnectionMetadataActions;
const diagnostics = {
refreshDiagnosticProbes,
refreshServerDiagnostics,
} satisfies ChatConnectionDiagnosticsActions;
const host: ChatConnectionControllerHost = {
stateStore,
@ -58,7 +58,7 @@ function createController({ connected = false, client = {} as AppServerClient }
controller: createChatConnectionController(host),
host,
refreshAppServerMetadata,
refreshDiagnosticProbes,
refreshServerDiagnostics,
stateStore,
};
}
@ -82,14 +82,14 @@ describe("ChatConnectionController", () => {
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
});
it("refreshes metadata before metadata-backed diagnostics", async () => {
const { controller, host, refreshAppServerMetadata, refreshDiagnosticProbes } = createController({ connected: true });
it("refreshes metadata before server diagnostics", async () => {
const { controller, host, refreshAppServerMetadata, refreshServerDiagnostics } = createController({ connected: true });
await controller.refreshDiagnostics();
expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2);
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(refreshDiagnosticProbes).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
});
it("refreshes active threads without refreshing metadata", async () => {

View file

@ -16,6 +16,7 @@ import { threadFromThreadRecord } from "../../../../../src/app-server/protocol/t
import { createChatServerDiagnosticsActions } from "../../../../../src/features/chat/app-server/actions/diagnostics";
import { createChatServerMetadataActions } from "../../../../../src/features/chat/app-server/actions/metadata";
import { createChatServerThreadActions } from "../../../../../src/features/chat/app-server/actions/threads";
import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/application/connection/tool-inventory-display";
import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import {
@ -251,7 +252,7 @@ describe("chat server actions", () => {
const state = chatStateFixture();
const stateStore = createChatStateStore(state);
const listHooks = vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", hooks: [] }] });
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
const refreshedMetadata = serverMetadataFixture({
availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-5.1")]),
availableSkills: [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }],
@ -266,10 +267,7 @@ describe("chat server actions", () => {
});
const refreshAppServerMetadata = vi.fn<() => Promise<SharedServerMetadata | null>>().mockResolvedValue(refreshedMetadata);
const client = {
listHooks,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listCollaborationModes: vi.fn().mockResolvedValue({ data: [] }),
readModelProviderCapabilities: vi.fn().mockResolvedValue({}),
listMcpServerStatus,
} as unknown as AppServerClient;
const metadataCache = metadataCacheHost({ current: null });
@ -293,10 +291,10 @@ describe("chat server actions", () => {
await metadata.refreshAppServerMetadata();
await diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(listHooks).toHaveBeenCalledWith("/vault");
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
expect(stateStore.getState().connection.serverDiagnostics.probes["model/list"]).toMatchObject({
status: "ok",
summary: "1 models",
@ -339,15 +337,12 @@ describe("chat server actions", () => {
const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] });
const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] });
const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null });
const listHooks = vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", hooks: [] }] });
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
const client = {
listModels,
listSkills,
readAccountRateLimits,
listHooks,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listCollaborationModes: vi.fn().mockResolvedValue({ data: [] }),
readModelProviderCapabilities: vi.fn().mockResolvedValue({}),
listMcpServerStatus,
} as unknown as AppServerClient;
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
@ -356,7 +351,7 @@ describe("chat server actions", () => {
...metadataCache,
});
await diagnostics.refreshDiagnosticProbes();
await diagnostics.refreshServerDiagnostics();
expect(listModels).not.toHaveBeenCalled();
expect(listSkills).not.toHaveBeenCalled();
@ -365,7 +360,7 @@ describe("chat server actions", () => {
status: "ok",
summary: "cached models",
});
expect(listHooks).toHaveBeenCalledWith("/vault");
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
});
it("can force resource probes for explicit health checks", async () => {
@ -377,10 +372,7 @@ describe("chat server actions", () => {
listModels,
listSkills,
readAccountRateLimits,
listHooks: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", hooks: [] }] }),
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listCollaborationModes: vi.fn().mockResolvedValue({ data: [] }),
readModelProviderCapabilities: vi.fn().mockResolvedValue({}),
} as unknown as AppServerClient;
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
@ -389,7 +381,7 @@ describe("chat server actions", () => {
...metadataCacheHost(),
});
await diagnostics.refreshDiagnosticProbes({ forceResourceProbes: true });
await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true });
expect(listModels).toHaveBeenCalledWith(false);
expect(listSkills).toHaveBeenCalledWith("/vault");
@ -402,13 +394,10 @@ describe("chat server actions", () => {
it("does not apply or publish diagnostic probes after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const hooksRefresh = deferred<{ data: { cwd: string; hooks: unknown[] }[] }>();
const listHooks = vi.fn().mockReturnValue(hooksRefresh.promise);
const mcpStatusRefresh = deferred<{ data: ReturnType<typeof mcpServerStatus>[] }>();
const listMcpServerStatus = vi.fn().mockReturnValue(mcpStatusRefresh.promise);
const firstClient = {
listHooks,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [mcpServerStatus()] }),
listCollaborationModes: vi.fn().mockResolvedValue({ data: [{ mode: "default" }] }),
readModelProviderCapabilities: vi.fn().mockResolvedValue({ namespaceTools: true, imageGeneration: false, webSearch: false }),
listMcpServerStatus,
} as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
@ -421,13 +410,12 @@ describe("chat server actions", () => {
updateAppServerMetadata,
});
const refreshing = diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
const refreshing = diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
currentClient = secondClient;
hooksRefresh.resolve({ data: [{ cwd: "/vault", hooks: [{}] }] });
mcpStatusRefresh.resolve({ data: [mcpServerStatus()] });
await refreshing;
expect(listHooks).toHaveBeenCalledWith("/vault");
expect(stateStore.getState().connection.serverDiagnostics.probes["hooks/list"].status).toBe("unknown");
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
expect(stateStore.getState().connection.serverDiagnostics.probes["mcpServerStatus/list"].status).toBe("unknown");
expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toEqual([]);
expect(updateAppServerMetadata).not.toHaveBeenCalled();
@ -600,13 +588,16 @@ describe("chat server actions", () => {
expect(updateAppServerMetadata).not.toHaveBeenCalled();
});
it("loads MCP status lines with cached startup diagnostics", async () => {
it("refreshes tool provider snapshots with cached MCP startup diagnostics", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
const stateStore = createChatStateStore(state);
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [mcpServerStatus()] });
const client = {
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
listInstalledPlugins: vi.fn().mockResolvedValue({ marketplaces: [], marketplaceLoadErrors: [] }),
listMcpServerStatus,
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
} as unknown as AppServerClient;
const metadataCache = metadataCacheHost({ current: serverMetadataFixture() });
const controller = createChatServerDiagnosticsActions({
@ -618,7 +609,16 @@ describe("chat server actions", () => {
controller.recordMcpStartupStatus("github", "ready", null);
await expect(controller.mcpStatusLines()).resolves.toEqual(["MCP servers", "github: ready, auth oAuth, 1 tool, 0 resources"]);
await controller.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
const sections = toolInventoryDiagnosticSections(stateStore.getState().connection.serverDiagnostics);
const providerRows = sections.find((section) => section.title === "Tool providers")?.rows ?? [];
expect(sections.map((section) => section.title)).toEqual(["Plugins", "Tool providers", "Skills"]);
expect(providerRows.map((row) => `${row.label}: ${row.value}`)).toEqual([
"codex_apps: (none)",
"github: MCP server, ready, auth oAuth, 1 tool, 0 resources",
]);
expect(listMcpServerStatus).toHaveBeenCalledWith({
detail: "toolsAndAuthOnly",
limit: 100,

View file

@ -48,7 +48,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
},
statusSummaryLines: () => ["status"],
connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }],
mcpStatusLines: vi.fn().mockResolvedValue(["mcp"]),
toolInventoryDetails: vi.fn(() => [{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "GitHub" }] }]),
modelStatusLines: () => ["model"],
effortStatusLines: () => ["effort"],
...overrides,
@ -498,7 +498,7 @@ describe("slash commands", () => {
expect(slashCommandHelpSections()).toEqual([
{
title: "Panel actions",
rows: expect.arrayContaining([
auditFacts: expect.arrayContaining([
{ key: "/clear", value: "Clear the current panel and start a fresh Codex thread." },
{ key: "/reconnect", value: "Reconnect to Codex app-server and resume the active thread." },
{ key: "/archive <thread>", value: "Archive the selected Codex thread." },
@ -507,21 +507,23 @@ describe("slash commands", () => {
},
{
title: "Thread settings",
rows: expect.arrayContaining([
auditFacts: expect.arrayContaining([
{ key: "/plan [message]", value: "Toggle Plan mode, optionally with a message." },
{ key: "/goal", value: "Show or manage the current thread goal." },
{ key: "/goal set <objective>", value: "Create or update the thread goal." },
{ key: "/model [model|default]", value: "Show or set the model for subsequent turns." },
]),
},
{
title: "Diagnostics",
rows: expect.arrayContaining([
auditFacts: expect.arrayContaining([
{ key: "/status", value: "Show current thread, context, and usage limits." },
{ key: "/help", value: "Show available Codex slash commands." },
]),
},
{
title: "Composition",
rows: [{ key: "/refer <thread> <message>", value: "Send a message with recent turns from another non-archived thread." }],
auditFacts: [{ key: "/refer <thread> <message>", value: "Send a message with recent turns from another non-archived thread." }],
},
]);
});
@ -585,6 +587,13 @@ describe("slash commands", () => {
expect(slashCommandHelpValue("/plan [message]")).toBe("Toggle Plan mode, optionally with a message.");
});
it("expands goal subcommands in help", () => {
expect(slashCommandHelpValue("/goal [set <objective>|edit|pause|resume|clear]")).toBeUndefined();
expect(slashCommandHelpValue("/goal")).toBe("Show or manage the current thread goal.");
expect(slashCommandHelpValue("/goal set <objective>")).toBe("Create or update the thread goal.");
expect(slashCommandHelpValue("/goal clear")).toBe("Clear the current thread goal.");
});
it("documents auto-review", () => {
expect(slashCommandHelpValue("/auto-review")).toBe("Toggle approval auto-review.");
});
@ -683,22 +692,24 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort set to CaseSensitive for subsequent turns.");
});
it("shows MCP server status", async () => {
it("shows Codex capabilities", async () => {
const ctx = context();
await executeSlashCommand("mcp", "", ctx);
await executeSlashCommand("tools", "", ctx);
expect(ctx.mcpStatusLines).toHaveBeenCalledOnce();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("MCP servers", [{ auditFacts: [] }]);
expect(ctx.toolInventoryDetails).toHaveBeenCalledOnce();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Codex capabilities", [
{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "GitHub" }] },
]);
});
it("rejects /mcp arguments", async () => {
it("rejects /tools arguments", async () => {
const ctx = context();
await executeSlashCommand("mcp", "enable github", ctx);
await executeSlashCommand("tools", "install github", ctx);
expect(ctx.mcpStatusLines).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/mcp does not take arguments. Usage: /mcp");
expect(ctx.toolInventoryDetails).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/tools does not take arguments. Usage: /tools");
});
it("rejects /fast arguments before toggling", async () => {
@ -710,13 +721,13 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fast does not take arguments. Usage: /fast");
});
it("documents MCP status", () => {
expect(slashCommandHelpValue("/mcp")).toBe("Show MCP servers reported by Codex App Server.");
it("documents Codex capabilities", () => {
expect(slashCommandHelpValue("/tools")).toBe("Show Codex plugins, tool providers, and skills reported by App Server.");
});
});
function slashCommandHelpValue(key: string): string | undefined {
return slashCommandHelpSections()
.flatMap((section) => section.rows)
.flatMap((section) => section.auditFacts)
.find((row) => row.key === key)?.value;
}

View file

@ -66,7 +66,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
setStatus: vi.fn(),
statusSummaryLines: () => [],
connectionDiagnosticDetails: () => [],
mcpStatusLines: vi.fn().mockResolvedValue([]),
toolInventoryDetails: vi.fn(() => []),
modelStatusLines: () => [],
effortStatusLines: () => [],
...overrides,

View file

@ -8,13 +8,22 @@ import {
upsertMcpServerDiagnostic,
upsertMcpServerStatusDiagnostics,
} from "../../../src/domain/server/diagnostics";
import type { ToolInventorySnapshot } from "../../../src/domain/server/tool-inventory";
import { connectionDiagnosticSections } from "../../../src/features/chat/application/connection/diagnostics-display";
import {
toolInventoryDiagnosticSections,
toolInventorySections,
} from "../../../src/features/chat/application/connection/tool-inventory-display";
describe("connection diagnostics", () => {
it("formats base rows, capability probes, and MCP issues for /doctor", () => {
it("formats connection rows and runtime checks for /doctor", () => {
let diagnostics = createServerDiagnostics();
diagnostics = diagnosticsWithProbe(diagnostics, diagnosticProbeOk("model/list", "12 models", 1));
diagnostics = diagnosticsWithProbe(diagnostics, diagnosticProbeError("skills/list", new Error("unknown method skills/list"), 2));
diagnostics = diagnosticsWithProbe(
diagnostics,
diagnosticProbeError("account/rateLimits/read", new Error("rate limit request failed"), 2),
);
diagnostics = diagnosticsWithProbe(diagnostics, diagnosticProbeError("skills/list", new Error("unknown method skills/list"), 3));
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: "github",
startupStatus: "failed",
@ -43,21 +52,18 @@ describe("connection diagnostics", () => {
});
const rows = sections.flatMap((section) => section.rows);
expect(sections.map((section) => section.title)).toEqual(["Process", "App Server Checks", "MCP issues"]);
expect(sections.map((section) => section.title)).toEqual(["Process", "Runtime Checks"]);
expect(rows.map((row) => `${row.label}: ${row.value}`)).toEqual(
expect.arrayContaining([
"connection: connected",
"model/list: ok (12 models)",
"skills/list: failed - unknown method skills/list",
"mcp docs: ready - auth notLoggedIn",
"mcp github: failed - missing token",
"account/rateLimits/read: failed - rate limit request failed",
]),
);
expect(rows.find((row) => row.label === "skills/list")?.level).toBe("error");
expect(rows.find((row) => row.label === "mcp github")?.level).toBe("error");
expect(sections.find((section) => section.title === "MCP issues")?.rows).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "mcp github", value: "failed - missing token" })]),
);
expect(rows.find((row) => row.label === "account/rateLimits/read")?.level).toBe("error");
expect(rows.find((row) => row.label === "skills/list")).toBeUndefined();
expect(rows.find((row) => row.label === "mcpServerStatus/list")).toBeUndefined();
expect(rows.find((row) => row.label === "mcp github")).toBeUndefined();
});
it("maps app-server MCP status snapshots into diagnostics", () => {
@ -89,4 +95,250 @@ describe("connection diagnostics", () => {
},
]);
});
it("summarizes usable Codex capabilities and groups skills by provenance", () => {
const inventory: ToolInventorySnapshot = {
checkedAt: 1,
apps: [
{
id: "enabled-app",
name: "Enabled App",
description: null,
enabled: true,
accessible: true,
distributionChannel: null,
pluginDisplayNames: [],
},
{
id: "catalog-candidate",
name: "Catalog Candidate",
description: null,
enabled: true,
accessible: false,
distributionChannel: "DEFAULT_OAI_CATALOG",
pluginDisplayNames: [],
},
{
id: "disabled-accessible-app",
name: "Disabled Accessible App",
description: null,
enabled: false,
accessible: true,
distributionChannel: null,
pluginDisplayNames: [],
},
],
appsError: null,
plugins: [
{
id: "usable-plugin",
name: "usable-plugin",
displayName: "Usable Plugin",
marketplaceName: "personal",
marketplacePath: null,
localVersion: "1.2.3",
installed: true,
enabled: true,
availability: "AVAILABLE",
source: "remote",
details: { skillCount: 2, hookCount: 1, appCount: 1, mcpServerCount: 1 },
detailsError: null,
},
{
id: "installable-plugin",
name: "installable-plugin",
displayName: "Installable Plugin",
marketplaceName: "directory",
marketplacePath: null,
localVersion: null,
installed: false,
enabled: true,
availability: "AVAILABLE",
source: "remote",
details: null,
detailsError: null,
},
{
id: "disabled-plugin",
name: "disabled-plugin",
displayName: "Disabled Plugin",
marketplaceName: "personal",
marketplacePath: null,
localVersion: "2.0.0",
installed: true,
enabled: false,
availability: "AVAILABLE",
source: "remote",
details: null,
detailsError: null,
},
],
pluginMarketplaceErrors: [],
pluginsError: null,
mcpServers: [
{
name: "codex_apps",
authStatus: "oAuth",
toolCount: 219,
resourceCount: 0,
resourceTemplateCount: 0,
},
{
name: "github",
authStatus: "oAuth",
toolCount: 2,
resourceCount: 0,
resourceTemplateCount: 0,
},
],
mcpDiagnostics: [
{
name: "codex_apps",
startupStatus: "ready",
authStatus: "oAuth",
toolCount: 219,
message: null,
},
{
name: "github",
startupStatus: "ready",
authStatus: "oAuth",
toolCount: 2,
message: null,
},
],
mcpError: null,
skills: [
{
name: "codex-panel-local",
description: "Local panel skill",
path: "/Users/showhey/Repos/github.com/murashit/codex-panel/.codex/skills/codex-panel-local/SKILL.md",
enabled: true,
},
{
name: "jujutsu-agent-workflow",
description: "Personal skill",
path: "/Users/showhey/.agents/skills/jujutsu-agent-workflow/SKILL.md",
enabled: true,
},
{
name: "openai-docs",
description: "System skill",
path: "/Users/showhey/.codex/skills/.system/openai-docs/SKILL.md",
enabled: true,
},
{
name: "github:gh-fix-ci",
description: "GitHub CI skill",
path: "/Users/showhey/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/gh-fix-ci/SKILL.md",
enabled: true,
},
{
name: "github:github",
description: "GitHub skill",
path: "/Users/showhey/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/github/SKILL.md",
enabled: true,
},
{
name: "gmail:gmail",
description: "Disabled skill",
path: "/Users/showhey/.codex/plugins/cache/openai-curated-remote/gmail/0.1.3/skills/gmail/SKILL.md",
enabled: false,
},
],
skillsError: null,
};
const sections = toolInventorySections(inventory);
const pluginRows = sections.find((section) => section.title === "Plugins")?.rows ?? [];
const providerRows = sections.find((section) => section.title === "Tool providers")?.rows ?? [];
const skillRows = sections.find((section) => section.title === "Skills")?.rows ?? [];
expect(sections.map((section) => section.title)).toEqual(["Plugins", "Tool providers", "Skills"]);
expect(pluginRows.map((row) => `${row.label}: ${row.value}`)).toEqual(["Usable Plugin: 2 skills, 1 hook, 1 app, 1 MCP server"]);
expect(providerRows.map((row) => `${row.label}: ${row.value}`)).toEqual([
"codex_apps: Enabled App",
"github: MCP server, ready, auth oAuth, 2 tools, 0 resources",
]);
expect(skillRows.map((row) => `${row.label}: ${row.value}`)).toEqual([
"codex-panel: codex-panel-local",
"Personal: jujutsu-agent-workflow",
"System: openai-docs",
"GitHub: gh-fix-ci, github",
]);
});
it("projects Codex capabilities from the latest diagnostic snapshot", () => {
const inventory: ToolInventorySnapshot = {
checkedAt: 1,
apps: [],
appsError: null,
plugins: [],
pluginMarketplaceErrors: [],
pluginsError: null,
mcpServers: [
{
name: "github",
authStatus: "oAuth",
toolCount: 1,
resourceCount: 0,
resourceTemplateCount: 0,
},
],
mcpDiagnostics: [],
mcpError: null,
skills: [],
skillsError: null,
};
let diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), {
name: "github",
startupStatus: "ready",
authStatus: null,
toolCount: null,
message: null,
});
diagnostics = {
...diagnostics,
toolInventory: inventory,
};
const providerRows = toolInventoryDiagnosticSections(diagnostics).find((section) => section.title === "Tool providers")?.rows ?? [];
expect(providerRows.map((row) => `${row.label}: ${row.value}`)).toEqual([
"codex_apps: (none)",
"github: MCP server, ready, auth oAuth, 1 tool, 0 resources",
]);
});
it("keeps diagnostic-only MCP server failures in Tool providers", () => {
const inventory: ToolInventorySnapshot = {
checkedAt: 1,
apps: [],
appsError: null,
plugins: [],
pluginMarketplaceErrors: [],
pluginsError: null,
mcpServers: [],
mcpDiagnostics: [
{
name: "figma",
startupStatus: "failed",
authStatus: null,
toolCount: null,
message: "command not found",
},
],
mcpError: null,
skills: [],
skillsError: null,
};
const providerRows = toolInventorySections(inventory).find((section) => section.title === "Tool providers")?.rows ?? [];
expect(providerRows.map((row) => `${row.label}: ${row.value}`)).toEqual([
"codex_apps: (none)",
"figma: MCP server, failed, auth unknown, tools unknown, command not found",
]);
expect(providerRows.find((row) => row.label === "figma")?.level).toBe("error");
});
});

View file

@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import type { McpServerStatusSummary } from "../../../src/domain/server/diagnostics";
import { mcpStatusLines } from "../../../src/features/chat/application/connection/diagnostics-display";
function mcpServer(overrides: Partial<McpServerStatusSummary> = {}): McpServerStatusSummary {
return {
name: "github",
authStatus: "oAuth",
toolCount: 2,
resourceCount: 0,
resourceTemplateCount: 0,
...overrides,
};
}
describe("mcpStatusLines", () => {
it("reports no configured servers clearly", () => {
expect(mcpStatusLines([])).toEqual(["MCP servers", "Codex App Server reports no MCP servers."]);
});
it("formats recognized MCP servers", () => {
expect(mcpStatusLines([mcpServer()])).toEqual(["MCP servers", "github: available, auth oAuth, 2 tools, 0 resources"]);
});
it("includes diagnostic-only startup failures", () => {
expect(
mcpStatusLines(
[],
[
{
name: "figma",
startupStatus: "failed",
authStatus: null,
toolCount: null,
message: "command not found",
},
],
),
).toEqual(["MCP servers", "figma: failed, auth unknown, tools unknown, command not found"]);
});
});

View file

@ -310,6 +310,7 @@ function toolbarActionsFixture(): ChatPanelShellParts["toolbar"]["actions"] {
toggleStatusPanel: vi.fn(),
connect: vi.fn(),
refreshStatus: vi.fn(),
copyDebugDetails: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: vi.fn(),
archiveThread: vi.fn(),

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { h, type ComponentChild } from "preact";
import { createServerDiagnostics } from "../../../../../src/domain/server/diagnostics";
@ -67,9 +67,14 @@ describe("chat panel surface projections", () => {
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-debug")] } });
state = chatStateWith(state, { runtime: { requestedModel: { kind: "set", value: "gpt-debug" } } });
const parent = renderWithShellState(state, h(ChatPanelToolbar, { surface: toolbarSurfaceFixture(), actions: toolbarActionsFixture() }));
const copyDebugDetails = vi.fn<(details: string) => void>();
const parent = renderWithShellState(
state,
h(ChatPanelToolbar, { surface: toolbarSurfaceFixture(), actions: toolbarActionsFixture({ copyDebugDetails }) }),
);
const debugContent = parent.querySelector(".codex-panel__debug-details-content")?.textContent;
parent.querySelectorAll<HTMLButtonElement>(".codex-panel__status-panel-item")[2]?.click();
const debugContent = copyDebugDetails.mock.calls[0]?.[0];
if (!debugContent) throw new Error("Expected toolbar debug details");
const debugDetails = JSON.parse(debugContent) as Record<string, unknown>;
@ -419,7 +424,7 @@ function toolbarSurfaceFixture(overrides: { archiveExportEnabled?: boolean } = {
};
}
function toolbarActionsFixture(): ToolbarActions {
function toolbarActionsFixture(overrides: Partial<ToolbarActions> = {}): ToolbarActions {
return {
startNewThread: () => undefined,
toggleChatActions: () => undefined,
@ -429,6 +434,7 @@ function toolbarActionsFixture(): ToolbarActions {
toggleStatusPanel: () => undefined,
connect: () => undefined,
refreshStatus: () => undefined,
copyDebugDetails: () => undefined,
resumeThread: () => undefined,
startArchiveThread: () => undefined,
archiveThread: () => undefined,
@ -437,6 +443,7 @@ function toolbarActionsFixture(): ToolbarActions {
saveRenameThread: () => undefined,
cancelRenameThread: () => undefined,
autoNameThread: () => undefined,
...overrides,
};
}

View file

@ -80,6 +80,7 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarActio
toggleStatusPanel: vi.fn(),
connect: vi.fn(),
refreshStatus: vi.fn(),
copyDebugDetails: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: (threadId) => {
toolbarActions.startArchive(threadId);

View file

@ -161,50 +161,49 @@ describe("Toolbar decisions", () => {
openPanel: "status",
diagnostics: [
{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/1.2.3" }] },
{ title: "App Server Checks", rows: [{ label: "diagnostics", value: "model/list failed", level: "error" }] },
{ title: "Runtime Checks", rows: [{ label: "diagnostics", value: "model/list failed", level: "error" }] },
],
}),
toolbarActions({ refreshStatus }),
);
expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection");
expect(parent.textContent).toContain("Codex capabilities");
expect(parent.textContent).toContain("Tool providers");
expect(parent.textContent).toContain("Process");
expect(parent.textContent).toContain("App Server Checks");
expect(parent.textContent).toContain("Debug details");
expect(parent.textContent).toContain("Refresh status");
expect(parent.textContent).not.toContain("Refresh diagnostics");
expect(parent.textContent).not.toContain("Refresh thread list");
expect(parent.textContent).toContain("Runtime Checks");
expect(parent.textContent).toContain("Copy debug details");
expect(parent.textContent).toContain("Refresh");
expect(parent.textContent).toContain("codex-cli/1.2.3");
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")?.textContent).toContain("model/list failed");
const statusItems = [...parent.querySelectorAll<HTMLElement>(".codex-panel__status-panel-item")];
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem"]);
expect(statusItems.map((item) => item.getAttribute("role"))).toEqual(["menuitem", "menuitem", "menuitem"]);
expect(statusItems.every((item) => item.getAttribute("aria-selected") === null)).toBe(true);
statusItems.find((item) => item.textContent.includes("Refresh status"))?.click();
statusItems.find((item) => item.textContent.includes("Refresh"))?.click();
expect(refreshStatus).toHaveBeenCalled();
});
it("renders raw debug details inside a collapsed status menu section", () => {
it("copies raw debug details from the status menu", () => {
const parent = document.createElement("div");
const copyDebugDetails = vi.fn();
const debugDetails = JSON.stringify({ runtimeConfig: { model: "gpt-5.5" } }, null, 2);
mountToolbar(
parent,
toolbarModel({
statusPanelOpen: true,
openPanel: "status",
debugDetails: JSON.stringify({ runtimeConfig: { model: "gpt-5.5" } }, null, 2),
debugDetails,
}),
toolbarActions(),
toolbarActions({ copyDebugDetails }),
);
expect(parent.querySelector(".codex-panel__region--config")).toBeNull();
const details = expectPresent(parent.querySelector<HTMLDetailsElement>(".codex-panel__debug-details"));
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Debug details");
expect(details.querySelector("pre")?.textContent).toContain('"model": "gpt-5.5"');
expect(parent.querySelector(".codex-panel__debug-details")).toBeNull();
expect(parent.textContent).not.toContain('"model": "gpt-5.5"');
parent.querySelectorAll<HTMLButtonElement>(".codex-panel__status-panel-item")[2]?.click();
expect(copyDebugDetails).toHaveBeenCalledWith(debugDetails);
expect(parent.querySelector(".codex-panel__toolbar-panel .codex-panel__config")).toBeNull();
expect(parent.textContent).not.toContain("Effective Codex config");
expect(parent.textContent).not.toContain("Show effective config");
expect(parent.textContent).not.toContain("Hide effective config");
});
it("renders thread list rename actions and an inline rename editor", () => {
@ -366,6 +365,7 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
threads: [{ title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }],
connectLabel: "Reconnect",
diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }],
toolInventory: [{ title: "Tool providers", rows: [{ label: "Codex capabilities", value: "not loaded", level: "warning" }] }],
...overrides,
};
}
@ -380,6 +380,7 @@ function toolbarActions(overrides: Partial<ToolbarActions> = {}): ToolbarActions
toggleStatusPanel: vi.fn(),
connect: vi.fn(),
refreshStatus: vi.fn(),
copyDebugDetails: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: vi.fn(),
archiveThread: vi.fn(),