From 759adabb9cad8a15bc4022d49f4f92bb686d6bb5 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 20 Jun 2026 14:46:47 +0900 Subject: [PATCH] Add tool source diagnostics --- README.md | 4 +- src/app-server/catalog.ts | 2 +- src/app-server/connection/client.ts | 22 ++ src/app-server/protocol/tool-inventory.ts | 113 ++++++++ src/app-server/query/snapshots.ts | 2 + src/app-server/tool-inventory.ts | 236 +++++++++++++++ src/domain/server/diagnostics.ts | 16 +- src/domain/server/tool-inventory.ts | 65 +++++ .../chat/app-server/actions/diagnostics.ts | 125 +++----- .../application/composer/slash-commands.ts | 29 +- .../connection/connection-controller.ts | 4 +- .../connection/diagnostics-display.ts | 87 +----- .../connection/tool-inventory-display.ts | 264 +++++++++++++++++ .../application/conversation/composition.ts | 4 +- .../conversation/slash-command-execution.ts | 6 +- src/features/chat/host/connection-bundle.ts | 4 +- src/features/chat/host/session-graph.ts | 25 +- .../chat/panel/surface/toolbar-projection.tsx | 6 +- src/features/chat/panel/toolbar-actions.ts | 4 + src/features/chat/ui/toolbar.tsx | 49 ++-- src/styles/30-chat-layout.css | 30 -- .../app-server/app-server-diagnostics.test.ts | 6 +- tests/app-server/tool-inventory.test.ts | 79 +++++ .../connection/connection-controller.test.ts | 12 +- .../server-actions/server-actions.test.ts | 60 ++-- .../turns/slash-command-execution.test.ts | 43 ++- .../turns/slash-command-executor.test.ts | 2 +- tests/features/chat/diagnostics.test.ts | 274 +++++++++++++++++- tests/features/chat/mcp-status.test.ts | 42 --- tests/features/chat/panel/shell.test.tsx | 1 + .../chat/panel/surface/projection.test.ts | 15 +- .../chat/panel/toolbar-archive-state.test.tsx | 1 + .../chat/ui/renderers/toolbar.test.ts | 37 +-- 33 files changed, 1284 insertions(+), 385 deletions(-) create mode 100644 src/app-server/protocol/tool-inventory.ts create mode 100644 src/app-server/tool-inventory.ts create mode 100644 src/domain/server/tool-inventory.ts create mode 100644 src/features/chat/application/connection/tool-inventory-display.ts create mode 100644 tests/app-server/tool-inventory.test.ts delete mode 100644 tests/features/chat/mcp-status.test.ts diff --git a/README.md b/README.md index c2cdb2b3..f69379aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app-server/catalog.ts b/src/app-server/catalog.ts index 5f06bcab..e6d28ca7 100644 --- a/src/app-server/catalog.ts +++ b/src/app-server/catalog.ts @@ -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)), diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 42153286..62d27cc8 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -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 { + return this.request("app/list", params); + } + + listInstalledPlugins(cwd: string): Promise { + const params: PluginInstalledParams = { cwds: [cwd] }; + return this.request("plugin/installed", params); + } + + readPlugin(params: PluginReadParams): Promise { + return this.request("plugin/read", params); + } + listModels(includeHidden = false): Promise { return this.request("model/list", { includeHidden, diff --git a/src/app-server/protocol/tool-inventory.ts b/src/app-server/protocol/tool-inventory.ts new file mode 100644 index 00000000..4e891304 --- /dev/null +++ b/src/app-server/protocol/tool-inventory.ts @@ -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"; +} diff --git a/src/app-server/query/snapshots.ts b/src/app-server/query/snapshots.ts index 50cc1685..742548bc 100644 --- a/src/app-server/query/snapshots.ts +++ b/src/app-server/query/snapshots.ts @@ -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, }, }; } diff --git a/src/app-server/tool-inventory.ts b/src/app-server/tool-inventory.ts new file mode 100644 index 00000000..8a304e45 --- /dev/null +++ b/src/app-server/tool-inventory.ts @@ -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 { + 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>["data"]> { + let cursor: string | null = null; + const apps: Awaited>["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 { + 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[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; +} diff --git a/src/domain/server/diagnostics.ts b/src/domain/server/diagnostics.ts index 62855609..f0e572eb 100644 --- a/src/domain/server/diagnostics.ts +++ b/src/domain/server/diagnostics.ts @@ -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>; 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, diff --git a/src/domain/server/tool-inventory.ts b/src/domain/server/tool-inventory.ts new file mode 100644 index 00000000..dee4025b --- /dev/null +++ b/src/domain/server/tool-inventory.ts @@ -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, + }; +} diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts index 3bcd997a..e228c7b5 100644 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ b/src/features/chat/app-server/actions/diagnostics.ts @@ -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; - mcpStatusLines: () => Promise; + refreshServerDiagnostics: (options?: RefreshServerDiagnosticsOptions) => Promise; 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 { 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[] = []; 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 { const result = await readRateLimitMetadataProbe(client); - return { method: "account/rateLimits/read", probe: result.probe }; -} - -async function mcpStatusLines(host: ChatServerDiagnosticsActionsHost): Promise { - 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( method: DiagnosticProbeMethod, request: () => Promise, summarize: (response: T) => string | null, - mcpServerStatuses?: (response: T) => readonly McpServerStatusSummary[], ): Promise { 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[0] { - return { - detail: "toolsAndAuthOnly", - limit: 100, - ...(threadId ? { threadId } : {}), - }; -} diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 3d688422..002aa04a 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -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, + })), + ]; } diff --git a/src/features/chat/application/connection/connection-controller.ts b/src/features/chat/application/connection/connection-controller.ts index 749329bd..d5f18a0c 100644 --- a/src/features/chat/application/connection/connection-controller.ts +++ b/src/features/chat/application/connection/connection-controller.ts @@ -23,7 +23,7 @@ export interface ChatConnectionMetadataActions { } export interface ChatConnectionDiagnosticsActions { - refreshDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise; + refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise; } 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( diff --git a/src/features/chat/application/connection/diagnostics-display.ts b/src/features/chat/application/connection/diagnostics-display.ts index 6ea94ea6..60203435 100644 --- a/src/features/chat/application/connection/diagnostics-display.ts +++ b/src/features/chat/application/connection/diagnostics-display.ts @@ -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 { 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"}`; -} diff --git a/src/features/chat/application/connection/tool-inventory-display.ts b/src/features/chat/application/connection/tool-inventory-display.ts new file mode 100644 index 00000000..fb19e44a --- /dev/null +++ b/src/features/chat/application/connection/tool-inventory-display.ts @@ -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): 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 { + 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>(); + const provenanceRanks = new Map(); + for (const skill of inventory.skills) { + if (!skill.enabled) continue; + const provenance = skillProvenance(skill); + const skills = skillsByProvenance.get(provenance.label) ?? new Set(); + 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): 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 = { + 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; +} diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 80052aaa..2feab87d 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -31,7 +31,7 @@ export interface ConversationTurnActionsContext { modelStatusLines: () => string[]; effortStatusLines: () => string[]; statusSummaryLines: () => string[]; - mcpStatusLines: () => Promise; + toolInventoryDetails: () => MessageStreamNoticeSection[]; }; thread: { ensureRestoredThreadLoaded: () => Promise; @@ -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, }; diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index ce89fb3c..c172d475 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -51,7 +51,7 @@ export interface SlashCommandExecutionPorts { }; statusSummaryLines: () => string[]; connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; - mcpStatusLines: () => Promise; + 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); diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index 506125f2..f601f2b4 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -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 }); } }); }, diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 08b3a12b..d2336020 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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 })), })); diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 848b8519..b442ae15 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -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), }; } diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index a324c592..05c17254 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -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); }, diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index 72e8555f..e5339bd5 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -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 <>
+ { + actions.copyDebugDetails(model.debugDetails); + }} className="codex-panel__status-panel-item" role="menuitem" />
- - + + ); } @@ -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 (
-
Connection
+
{title}
{sections.map((section) => ( ))} @@ -224,31 +229,31 @@ function ConnectionDiagnostics({ sections }: { sections: ToolbarDiagnosticSectio ); } +function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode { + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +} + function DiagnosticSection({ section }: { section: ToolbarDiagnosticSection }): UiNode { return ( <>
{section.title}
-
- {section.rows.map((row) => ( -
-
{row.label}
-
{row.value}
-
- ))} -
+ ); } -function DebugDetails({ details }: { details: string }): UiNode { +function DiagnosticRow({ row }: { row: ToolbarDiagnosticRow }): UiNode { return ( -
- Debug details -
{details}
-
+
+
{row.label}
+
{row.value}
+
); } diff --git a/src/styles/30-chat-layout.css b/src/styles/30-chat-layout.css index d4ec0927..f148a50c 100644 --- a/src/styles/30-chat-layout.css +++ b/src/styles/30-chat-layout.css @@ -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, diff --git a/tests/app-server/app-server-diagnostics.test.ts b/tests/app-server/app-server-diagnostics.test.ts index d30e514a..997609cb 100644 --- a/tests/app-server/app-server-diagnostics.test.ts +++ b/tests/app-server/app-server-diagnostics.test.ts @@ -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"); }); diff --git a/tests/app-server/tool-inventory.test.ts b/tests/app-server/tool-inventory.test.ts new file mode 100644 index 00000000..130aa5a6 --- /dev/null +++ b/tests/app-server/tool-inventory.test.ts @@ -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[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], + ]); + }); +}); diff --git a/tests/features/chat/connection/connection-controller.test.ts b/tests/features/chat/connection/connection-controller.test.ts index c298f417..f56cbf76 100644 --- a/tests/features/chat/connection/connection-controller.test.ts +++ b/tests/features/chat/connection/connection-controller.test.ts @@ -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 () => { diff --git a/tests/features/chat/connection/server-actions/server-actions.test.ts b/tests/features/chat/connection/server-actions/server-actions.test.ts index 99db5f4d..6b3b60f0 100644 --- a/tests/features/chat/connection/server-actions/server-actions.test.ts +++ b/tests/features/chat/connection/server-actions/server-actions.test.ts @@ -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>().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[] }>(); + 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, diff --git a/tests/features/chat/conversation/turns/slash-command-execution.test.ts b/tests/features/chat/conversation/turns/slash-command-execution.test.ts index ef8a8422..c656a17f 100644 --- a/tests/features/chat/conversation/turns/slash-command-execution.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-execution.test.ts @@ -48,7 +48,7 @@ function context(overrides: Partial = {}): 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 ", 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 ", 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 ", value: "Send a message with recent turns from another non-archived thread." }], + auditFacts: [{ key: "/refer ", 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 |edit|pause|resume|clear]")).toBeUndefined(); + expect(slashCommandHelpValue("/goal")).toBe("Show or manage the current thread goal."); + expect(slashCommandHelpValue("/goal set ")).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; } diff --git a/tests/features/chat/conversation/turns/slash-command-executor.test.ts b/tests/features/chat/conversation/turns/slash-command-executor.test.ts index f844471d..fdee257a 100644 --- a/tests/features/chat/conversation/turns/slash-command-executor.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-executor.test.ts @@ -66,7 +66,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { setStatus: vi.fn(), statusSummaryLines: () => [], connectionDiagnosticDetails: () => [], - mcpStatusLines: vi.fn().mockResolvedValue([]), + toolInventoryDetails: vi.fn(() => []), modelStatusLines: () => [], effortStatusLines: () => [], ...overrides, diff --git a/tests/features/chat/diagnostics.test.ts b/tests/features/chat/diagnostics.test.ts index 6315a3fa..7152674c 100644 --- a/tests/features/chat/diagnostics.test.ts +++ b/tests/features/chat/diagnostics.test.ts @@ -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"); + }); }); diff --git a/tests/features/chat/mcp-status.test.ts b/tests/features/chat/mcp-status.test.ts deleted file mode 100644 index eb66acc7..00000000 --- a/tests/features/chat/mcp-status.test.ts +++ /dev/null @@ -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 { - 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"]); - }); -}); diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index d3c36acc..3e7e3dd1 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -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(), diff --git a/tests/features/chat/panel/surface/projection.test.ts b/tests/features/chat/panel/surface/projection.test.ts index b630d69b..a1644eb2 100644 --- a/tests/features/chat/panel/surface/projection.test.ts +++ b/tests/features/chat/panel/surface/projection.test.ts @@ -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(".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; @@ -419,7 +424,7 @@ function toolbarSurfaceFixture(overrides: { archiveExportEnabled?: boolean } = { }; } -function toolbarActionsFixture(): ToolbarActions { +function toolbarActionsFixture(overrides: Partial = {}): 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, }; } diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 8a6ef47f..c007ac71 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -80,6 +80,7 @@ function shellParts(store: ReturnType, toolbarActio toggleStatusPanel: vi.fn(), connect: vi.fn(), refreshStatus: vi.fn(), + copyDebugDetails: vi.fn(), resumeThread: vi.fn(), startArchiveThread: (threadId) => { toolbarActions.startArchive(threadId); diff --git a/tests/features/chat/ui/renderers/toolbar.test.ts b/tests/features/chat/ui/renderers/toolbar.test.ts index 4d9c861f..943adc8e 100644 --- a/tests/features/chat/ui/renderers/toolbar.test.ts +++ b/tests/features/chat/ui/renderers/toolbar.test.ts @@ -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(".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(".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(".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 = {}): 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 toggleStatusPanel: vi.fn(), connect: vi.fn(), refreshStatus: vi.fn(), + copyDebugDetails: vi.fn(), resumeThread: vi.fn(), startArchiveThread: vi.fn(), archiveThread: vi.fn(),