mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add tool source diagnostics
This commit is contained in:
parent
58e24197b3
commit
759adabb9c
33 changed files with 1284 additions and 385 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
113
src/app-server/protocol/tool-inventory.ts
Normal file
113
src/app-server/protocol/tool-inventory.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
236
src/app-server/tool-inventory.ts
Normal file
236
src/app-server/tool-inventory.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
65
src/domain/server/tool-inventory.ts
Normal file
65
src/domain/server/tool-inventory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
79
tests/app-server/tool-inventory.test.ts
Normal file
79
tests/app-server/tool-inventory.test.ts
Normal 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
|
|||
setStatus: vi.fn(),
|
||||
statusSummaryLines: () => [],
|
||||
connectionDiagnosticDetails: () => [],
|
||||
mcpStatusLines: vi.fn().mockResolvedValue([]),
|
||||
toolInventoryDetails: vi.fn(() => []),
|
||||
modelStatusLines: () => [],
|
||||
effortStatusLines: () => [],
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue