Organize status, doctor, and runtime diagnostics

This commit is contained in:
murashit 2026-05-22 10:42:56 +09:00
parent f5f9c5951a
commit 9b1770ee3f
11 changed files with 124 additions and 83 deletions

View file

@ -9,6 +9,11 @@ export interface DiagnosticRow {
level?: "normal" | "warning" | "error";
}
export interface DiagnosticSection {
title: string;
rows: DiagnosticRow[];
}
export type DiagnosticAlertLevel = "normal" | "warning" | "error";
export interface ConnectionDiagnosticsInput {
@ -19,24 +24,32 @@ export interface ConnectionDiagnosticsInput {
diagnostics: AppServerDiagnostics;
}
export function connectionDiagnosticRows(input: ConnectionDiagnosticsInput): DiagnosticRow[] {
export function connectionDiagnosticSections(input: ConnectionDiagnosticsInput): DiagnosticSection[] {
const mcpRows = mcpServerDiagnosticRows(input.diagnostics.mcpServers);
return [
{ label: "connection", value: input.connected ? "connected" : "offline" },
{ label: "configured command", value: input.configuredCommand },
{ label: "running app-server", value: appServerIdentity(input.initializeResponse) },
{ label: "panel client", value: CLIENT_VERSION },
{ label: "platform", value: appServerPlatform(input.initializeResponse) },
{ label: "codexHome", value: input.initializeResponse?.codexHome ?? "(not connected)" },
{ label: "active thread CLI", value: input.activeThreadCliVersion ?? "(none)" },
...CAPABILITY_PROBE_METHODS.map((method) => capabilityDiagnosticRow(input.diagnostics.probes[method])),
...mcpServerDiagnosticRows(input.diagnostics.mcpServers),
{
title: "Process",
rows: [
{ label: "connection", value: input.connected ? "connected" : "offline" },
{ label: "configured command", value: input.configuredCommand },
{ label: "running app-server", value: appServerIdentity(input.initializeResponse) },
{ label: "panel client", value: CLIENT_VERSION },
{ label: "platform", value: appServerPlatform(input.initializeResponse) },
{ label: "codexHome", value: input.initializeResponse?.codexHome ?? "(not connected)" },
{ label: "active thread CLI", value: input.activeThreadCliVersion ?? "(none)" },
],
},
{
title: "Capabilities",
rows: CAPABILITY_PROBE_METHODS.map((method) => capabilityDiagnosticRow(input.diagnostics.probes[method])),
},
{
title: "MCP issues",
rows: mcpRows.length > 0 ? mcpRows : [{ label: "issues", value: "(none)" }],
},
];
}
export function connectionDiagnosticLines(rows: DiagnosticRow[]): string[] {
return ["Connection diagnostics", ...rows.map((row) => `${row.label}: ${row.value}`)];
}
export function diagnosticAlertLevel(diagnostics: AppServerDiagnostics): DiagnosticAlertLevel {
let hasWarning = false;
for (const probe of Object.values(diagnostics.probes)) {
@ -52,7 +65,7 @@ export function diagnosticAlertLevel(diagnostics: AppServerDiagnostics): Diagnos
function capabilityDiagnosticRow(probe: CapabilityProbeResult): DiagnosticRow {
const detail = probe.message ? ` - ${probe.message}` : probe.summary ? ` (${probe.summary})` : "";
return {
label: `capability ${probe.method}`,
label: probe.method,
value: `${probe.status}${detail}`,
level: capabilityLevel(probe.status),
};

View file

@ -32,7 +32,7 @@ export interface SlashCommandExecutionContext {
setRequestedModel: (model: string | null) => void | Promise<void>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => void | Promise<void>;
statusSummaryLines: () => string[];
connectionDiagnosticLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
@ -181,7 +181,7 @@ export async function executeSlashCommand(
}
if (command === "doctor") {
context.addStructuredSystemMessage("Connection diagnostics", detailsFromLines(context.connectionDiagnosticLines()));
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;
}

View file

@ -21,7 +21,7 @@ import {
nextCollaborationMode,
} from "../runtime/collaboration-mode";
import { PanelController } from "./controller";
import { connectionDiagnosticLines, connectionDiagnosticRows, diagnosticAlertLevel } from "./diagnostics";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import { rollbackCandidateFromItems } from "./rollback";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../runtime/view";
import {
@ -547,7 +547,7 @@ export class CodexPanelView extends ItemView {
setRequestedModel: (model) => this.setRequestedModel(model),
setRequestedReasoningEffort: (effort) => this.setRequestedReasoningEffort(effort),
statusSummaryLines: () => this.statusSummaryLines(),
connectionDiagnosticLines: () => this.connectionDiagnosticLines(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
mcpStatusLines: () => this.mcpStatusLines(),
modelStatusLines: () => this.modelStatusLines(),
effortStatusLines: () => this.effortStatusLines(),
@ -864,7 +864,7 @@ export class CodexPanelView extends ItemView {
modelChoices: this.modelToolbarChoices(),
effortChoices: this.effortToolbarChoices(),
connectLabel: this.connection.isConnected() ? "Reconnect" : "Connect",
diagnostics: this.connectionDiagnosticRows(),
diagnostics: this.connectionDiagnosticSections(),
diagnosticAlertLevel: diagnosticAlertLevel(this.state.appServerDiagnostics),
};
}
@ -1026,8 +1026,8 @@ export class CodexPanelView extends ItemView {
];
}
private connectionDiagnosticRows() {
return connectionDiagnosticRows({
private connectionDiagnosticSections() {
return connectionDiagnosticSections({
connected: this.connection.isConnected(),
configuredCommand: this.plugin.settings.codexPath,
initializeResponse: this.state.initializeResponse,
@ -1036,8 +1036,11 @@ export class CodexPanelView extends ItemView {
});
}
private connectionDiagnosticLines(): string[] {
return connectionDiagnosticLines(this.connectionDiagnosticRows());
private connectionDiagnosticDetails(): DisplayDetailSection[] {
return this.connectionDiagnosticSections().map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));
}
private async mcpStatusLines(): Promise<string[]> {

View file

@ -35,9 +35,6 @@ export interface RuntimeSnapshot {
export interface TurnRuntimeSettings {
collaborationMode: CollaborationMode | null;
model: string | null | undefined;
effort: ReasoningEffort | null | undefined;
approvalsReviewer: ApprovalsReviewer | undefined;
warning: string | null;
}
@ -98,9 +95,6 @@ export function requestedTurnRuntimeSettings(snapshot: RuntimeSnapshot): TurnRun
: null;
return {
collaborationMode,
model: runtimeOverridePayload(snapshot.requestedModel),
effort: runtimeOverridePayload(snapshot.requestedReasoningEffort),
approvalsReviewer: snapshot.requestedApprovalsReviewer ?? undefined,
warning: model ? null : "No effective model is available. Sending without a mode override.",
};
}
@ -140,10 +134,6 @@ export function resetRuntimeOverride<T>(): RuntimeOverride<T> {
return { kind: "resetPending" };
}
export function commitRuntimeOverride<T>(override: RuntimeOverride<T>): RuntimeOverride<T> {
return override.kind === "resetPending" ? defaultRuntimeOverride<T>() : override;
}
export function runtimeOverridePayload<T>(override: RuntimeOverride<T>): T | null | undefined {
if (override.kind === "set") return override.value;
if (override.kind === "resetPending") return null;
@ -152,8 +142,8 @@ export function runtimeOverridePayload<T>(override: RuntimeOverride<T>): T | nul
export function runtimeOverrideLabel<T>(override: RuntimeOverride<T>): string {
if (override.kind === "set") return String(override.value);
if (override.kind === "resetPending") return "(reset pending)";
return "(default)";
if (override.kind === "resetPending") return "(reset to config)";
return "(none)";
}
function configuredServiceTier(config: Record<string, unknown>): ServiceTier | null {

View file

@ -7,8 +7,8 @@ import {
currentModel,
currentReasoningEffort,
fastModeLabel,
runtimeOverrideLabel,
serviceTierLabel,
type RuntimeOverride,
type RuntimeSnapshot,
} from "./state";
@ -118,10 +118,10 @@ export function effectiveConfigSections(snapshot: RuntimeSnapshot, vaultPath: st
rows: [
{ key: "model", value: currentModel(snapshot, config) ?? "(from default)" },
{ key: "config model", value: configuredModel(snapshot, config) ?? "(not reported)" },
{ key: "model change", value: pendingRuntimeChangeLabel(snapshot.requestedModel) },
{ key: "model change", value: runtimeOverrideLabel(snapshot.requestedModel) },
{ key: "effort", value: currentReasoningEffort(snapshot, config) ?? "(from default)" },
{ key: "config effort", value: configuredReasoningEffort(snapshot, config) ?? "(not reported)" },
{ key: "effort change", value: pendingRuntimeChangeLabel(snapshot.requestedReasoningEffort) },
{ key: "effort change", value: runtimeOverrideLabel(snapshot.requestedReasoningEffort) },
{ key: "reasoning summary", value: stringValue(config.model_reasoning_summary, "(from default)") },
{ key: "verbosity", value: stringValue(config.model_verbosity, "(from default)") },
{ key: "mode", value: snapshot.activeCollaborationMode === "plan" ? "Plan" : "Default" },
@ -184,12 +184,6 @@ function modeLabel(mode: RuntimeSnapshot["requestedCollaborationMode"]): string
return mode === "plan" ? "Plan" : "Default";
}
function pendingRuntimeChangeLabel<T>(override: RuntimeOverride<T>): string {
if (override.kind === "set") return String(override.value);
if (override.kind === "resetPending") return "(reset to config)";
return "(none)";
}
function rateLimitWindowSummary(
fallbackLabel: string,
window: RateLimitWindow | null,

View file

@ -35,6 +35,11 @@ export interface ToolbarDiagnosticRow {
level?: "normal" | "warning" | "error";
}
export interface ToolbarDiagnosticSection {
title: string;
rows: ToolbarDiagnosticRow[];
}
export interface ToolbarViewModel {
connected: boolean;
status: string;
@ -57,7 +62,7 @@ export interface ToolbarViewModel {
modelChoices: ToolbarChoice[];
effortChoices: ToolbarChoice[];
connectLabel: string;
diagnostics: ToolbarDiagnosticRow[];
diagnostics: ToolbarDiagnosticSection[];
diagnosticAlertLevel: ToolbarDiagnosticAlertLevel;
}
@ -109,7 +114,10 @@ export function toolbarSignature(model: ToolbarViewModel): string {
modelChoices: model.modelChoices.map((choice) => `${choice.label}:${choice.selected}:${choice.disabled}:${choice.meta ?? ""}`),
effortChoices: model.effortChoices.map((choice) => `${choice.label}:${choice.selected}:${choice.disabled}:${choice.meta ?? ""}`),
connectLabel: model.connectLabel,
diagnostics: model.diagnostics.map((row) => `${row.label}:${row.value}:${row.level ?? "normal"}`),
diagnostics: model.diagnostics.map((section) => ({
title: section.title,
rows: section.rows.map((row) => `${row.label}:${row.value}:${row.level ?? "normal"}`),
})),
diagnosticAlertLevel: model.diagnosticAlertLevel,
});
}
@ -282,16 +290,19 @@ function renderRateLimitPanel(parent: HTMLElement, rateLimit: RateLimitSummary |
}
}
function renderConnectionDiagnostics(parent: HTMLElement, rows: ToolbarDiagnosticRow[]): void {
function renderConnectionDiagnostics(parent: HTMLElement, sections: ToolbarDiagnosticSection[]): void {
const diagnostics = parent.createDiv({ cls: "codex-panel__connection-diagnostics" });
diagnostics.createDiv({ cls: "codex-panel__connection-diagnostics-title", text: "Connection diagnostics" });
const list = diagnostics.createEl("dl", { cls: "codex-panel__connection-diagnostics-list" });
for (const row of rows) {
const item = list.createDiv({
cls: `codex-panel__connection-diagnostics-row codex-panel__connection-diagnostics-row--${row.level ?? "normal"}`,
});
item.createEl("dt", { text: row.label });
item.createEl("dd", { text: row.value });
diagnostics.createDiv({ cls: "codex-panel__connection-diagnostics-title", text: "Connection" });
for (const section of sections) {
diagnostics.createDiv({ cls: "codex-panel__connection-diagnostics-section", text: section.title });
const list = diagnostics.createEl("dl", { cls: "codex-panel__connection-diagnostics-list" });
for (const row of section.rows) {
const item = list.createDiv({
cls: `codex-panel__connection-diagnostics-row codex-panel__connection-diagnostics-row--${row.level ?? "normal"}`,
});
item.createEl("dt", { text: row.label });
item.createEl("dd", { text: row.value });
}
}
}

View file

@ -560,6 +560,18 @@
line-height: var(--line-height-tight);
}
.codex-panel__connection-diagnostics-section {
padding: 6px 8px 2px;
color: var(--text-faint);
font-size: var(--font-ui-smaller);
font-weight: var(--font-semibold);
line-height: var(--line-height-tight);
}
.codex-panel__connection-diagnostics-title + .codex-panel__connection-diagnostics-section {
padding-top: 0;
}
.codex-panel__connection-diagnostics-list {
margin: 0;
cursor: text;
@ -568,7 +580,7 @@
.codex-panel__connection-diagnostics-row {
display: grid;
grid-template-columns: minmax(96px, 36%) minmax(0, 1fr);
grid-template-columns: minmax(0, 36%) minmax(0, 1fr);
gap: var(--codex-panel-section-gap);
padding: 4px 8px;
}
@ -583,6 +595,7 @@
.codex-panel__connection-diagnostics-row dt {
color: var(--text-faint);
overflow-wrap: anywhere;
}
.codex-panel__connection-diagnostics-row dd {

View file

@ -28,7 +28,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
setRequestedModel: vi.fn(),
setRequestedReasoningEffort: vi.fn(),
statusSummaryLines: () => ["status"],
connectionDiagnosticLines: () => ["doctor"],
connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }],
mcpStatusLines: vi.fn().mockResolvedValue(["mcp"]),
modelStatusLines: () => ["model"],
effortStatusLines: () => ["effort"],
@ -322,6 +322,16 @@ describe("slash commands", () => {
]);
});
it("shows doctor diagnostics as shared structured sections", async () => {
const details = [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }];
const ctx = context({ connectionDiagnosticDetails: () => details });
await executeSlashCommand("doctor", "", ctx);
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Connection diagnostics", details);
});
it("documents that /plan can take a message", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/plan"))).toBe(
"/plan - Toggle Plan mode, optionally sending a message.",

View file

@ -6,7 +6,7 @@ import {
createAppServerDiagnostics,
upsertMcpServerDiagnostic,
} from "../../src/app-server/compatibility";
import { connectionDiagnosticLines, connectionDiagnosticRows, diagnosticAlertLevel } from "../../src/panel/diagnostics";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "../../src/panel/diagnostics";
describe("connection diagnostics", () => {
it("formats base rows, capability probes, and MCP issues for /doctor", () => {
@ -28,7 +28,7 @@ describe("connection diagnostics", () => {
message: null,
});
const rows = connectionDiagnosticRows({
const sections = connectionDiagnosticSections({
connected: true,
configuredCommand: "/opt/homebrew/bin/codex",
initializeResponse: {
@ -41,18 +41,22 @@ describe("connection diagnostics", () => {
diagnostics,
});
const rows = sections.flatMap((section) => section.rows);
expect(sections.map((section) => section.title)).toEqual(["Process", "Capabilities", "MCP issues"]);
expect(rows.map((row) => `${row.label}: ${row.value}`)).toEqual(
expect.arrayContaining([
"connection: connected",
"capability model/list: ok (12 models)",
"capability skills/list: unsupported - unknown method skills/list",
"model/list: ok (12 models)",
"skills/list: unsupported - unknown method skills/list",
"mcp docs: ready - auth notLoggedIn",
"mcp github: failed - missing token",
]),
);
expect(rows.find((row) => row.label === "capability skills/list")?.level).toBe("warning");
expect(rows.find((row) => row.label === "skills/list")?.level).toBe("warning");
expect(rows.find((row) => row.label === "mcp github")?.level).toBe("error");
expect(connectionDiagnosticLines(rows)[0]).toBe("Connection diagnostics");
expect(sections.find((section) => section.title === "MCP issues")?.rows).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "mcp github", value: "failed - missing token" })]),
);
});
it("derives a top-level diagnostic alert without treating unknown or unsupported probes as warnings", () => {

View file

@ -19,6 +19,7 @@ import {
requestedOrConfiguredServiceTier,
requestedTurnRuntimeSettings,
resetRuntimeOverride,
runtimeOverridePayload,
setRuntimeOverride,
serviceTierLabel,
type RuntimeSnapshot,
@ -61,7 +62,7 @@ describe("runtime settings", () => {
expect(compactContextLabel(null, "1.2K tokens")).toBe("1.2K tokens");
});
it("keeps runtime defaults, resets, and turn payload semantics distinct", () => {
it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => {
const snapshot = runtimeSnapshot({
requestedModel: resetRuntimeOverride(),
requestedReasoningEffort: resetRuntimeOverride(),
@ -70,16 +71,16 @@ describe("runtime settings", () => {
expect(currentModel(snapshot)).toBe("gpt-5.5");
expect(currentReasoningEffort(snapshot)).toBe("high");
expect(requestedTurnRuntimeSettings(snapshot)).toMatchObject({
model: null,
effort: null,
collaborationMode: {
mode: "default",
settings: { model: "gpt-5.5", reasoning_effort: "high" },
},
});
expect(runtimeOverridePayload(snapshot.requestedModel)).toBeNull();
expect(runtimeOverridePayload(snapshot.requestedReasoningEffort)).toBeNull();
});
it("serializes explicit runtime overrides as turn payload values", () => {
it("uses explicit runtime overrides as current values and settings payload values", () => {
const snapshot = runtimeSnapshot({
requestedModel: setRuntimeOverride("gpt-5.4"),
requestedReasoningEffort: setRuntimeOverride("low"),
@ -87,10 +88,8 @@ describe("runtime settings", () => {
expect(currentModel(snapshot)).toBe("gpt-5.4");
expect(currentReasoningEffort(snapshot)).toBe("low");
expect(requestedTurnRuntimeSettings(snapshot)).toMatchObject({
model: "gpt-5.4",
effort: "low",
});
expect(runtimeOverridePayload(snapshot.requestedModel)).toBe("gpt-5.4");
expect(runtimeOverridePayload(snapshot.requestedReasoningEffort)).toBe("low");
});
it("resolves approval reviewer from requested, active, then effective config", () => {
@ -120,13 +119,12 @@ describe("runtime settings", () => {
).toBe("guardian_subagent");
});
it("serializes requested approval reviewer as a turn override", () => {
it("resolves requested approval reviewer without adding it to turn runtime settings", () => {
const snapshot = runtimeSnapshot({ requestedApprovalsReviewer: "auto_review" });
expect(autoReviewActive(snapshot)).toBe(true);
expect(requestedTurnRuntimeSettings(snapshot)).toMatchObject({
approvalsReviewer: "auto_review",
});
expect(currentApprovalsReviewer(snapshot)).toBe("auto_review");
expect(requestedTurnRuntimeSettings(snapshot)).not.toHaveProperty("approvalsReviewer");
});
it("treats active thread runtime as display state without persisting it into turn overrides", () => {
@ -139,13 +137,13 @@ describe("runtime settings", () => {
expect(currentModel(snapshot)).toBe("gpt-5-active");
expect(currentServiceTier(snapshot)).toBe("fast");
expect(requestedTurnRuntimeSettings(snapshot)).toMatchObject({
model: undefined,
effort: undefined,
collaborationMode: {
mode: "default",
settings: { model: "gpt-5-active" },
},
});
expect(requestedTurnRuntimeSettings(snapshot)).not.toHaveProperty("model");
expect(requestedTurnRuntimeSettings(snapshot)).not.toHaveProperty("effort");
});
it("separates effective runtime, config defaults, and pending changes in status details", () => {

View file

@ -1558,7 +1558,10 @@ describe("toolbar renderer decisions", () => {
toolbarSignature({ ...baseModel, effortChoices: [...baseModel.effortChoices, { label: "xhigh", onClick: vi.fn() }] }),
);
expect(toolbarSignature(baseModel)).not.toBe(
toolbarSignature({ ...baseModel, diagnostics: [{ label: "compatibility", value: "model/list failed", level: "error" }] }),
toolbarSignature({
...baseModel,
diagnostics: [{ title: "Capabilities", rows: [{ label: "compatibility", value: "model/list failed", level: "error" }] }],
}),
);
expect(toolbarSignature(baseModel)).not.toBe(toolbarSignature({ ...baseModel, autoReviewActive: true }));
expect(toolbarSignature(baseModel)).not.toBe(
@ -1628,14 +1631,16 @@ describe("toolbar renderer decisions", () => {
statusPanelOpen: true,
openPanel: "status",
diagnostics: [
{ label: "running app-server", value: "codex-cli/1.2.3" },
{ label: "compatibility", value: "model/list failed", level: "error" },
{ title: "Process", rows: [{ label: "running app-server", value: "codex-cli/1.2.3" }] },
{ title: "Capabilities", rows: [{ label: "compatibility", value: "model/list failed", level: "error" }] },
],
}),
toolbarActions({ refreshDiagnostics }),
);
expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection diagnostics");
expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection");
expect(parent.textContent).toContain("Process");
expect(parent.textContent).toContain("Capabilities");
expect(parent.textContent).toContain("Effective Codex config");
expect(parent.textContent).toContain("Refresh diagnostics");
expect(parent.textContent).toContain("codex-cli/1.2.3");
@ -2153,7 +2158,7 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
modelChoices: [{ label: "Default", selected: true, onClick: vi.fn() }],
effortChoices: [{ label: "Default", selected: true, onClick: vi.fn() }],
connectLabel: "Reconnect",
diagnostics: [{ label: "running app-server", value: "codex-cli/test" }],
diagnostics: [{ title: "Process", rows: [{ label: "running app-server", value: "codex-cli/test" }] }],
diagnosticAlertLevel: "normal",
...overrides,
};