murashit_codex-panel/tests/features/chat/application/connection/server-actions.test.ts

460 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import type { RateLimitSnapshot } from "../../../../../src/domain/runtime/metrics";
import {
createServerDiagnostics,
type DiagnosticProbeResult,
diagnosticProbeOk,
diagnosticsWithProbe,
diagnosticsWithToolInventory,
upsertMcpServerDiagnostic,
} from "../../../../../src/domain/server/diagnostics";
import type { McpServerStatusSummary } from "../../../../../src/domain/server/mcp-status";
import type { SharedServerMetadata } from "../../../../../src/domain/server/metadata";
import type { ToolInventorySnapshot } from "../../../../../src/domain/server/tool-inventory";
import { createServerDiagnosticsActions } from "../../../../../src/features/chat/application/connection/server-diagnostics-actions";
import { createServerMetadataActions } from "../../../../../src/features/chat/application/connection/server-metadata-actions";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/tool-inventory-diagnostic-sections";
import { deferred } from "../../../../support/async";
import { chatStateFixture, chatStateWith } from "../../support/state";
describe("server metadata actions", () => {
it("applies refreshed non-model app-server metadata", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const metadata = serverMetadataFixture({ availableSkills: [skillFixture("writer")] });
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshAppServerMetadata: vi.fn().mockResolvedValue(metadata),
isStaleResourceContextError: () => false,
});
await actions.refreshAppServerMetadata();
expect(stateStore.getState().connection.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
});
it("preserves panel-local tool diagnostics when applying shared metadata", async () => {
const localDiagnostics = diagnosticsWithToolInventory(
upsertMcpServerDiagnostic(createServerDiagnostics(), {
name: "github",
startupStatus: "ready",
authStatus: null,
toolCount: null,
message: null,
}),
toolInventory(),
);
const stateStore = createChatStateStore(chatStateFixture({ connection: { serverDiagnostics: localDiagnostics } }));
const metadata = serverMetadataFixture({
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 model", 2)),
});
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshAppServerMetadata: vi.fn().mockResolvedValue(metadata),
isStaleResourceContextError: () => false,
});
await actions.refreshAppServerMetadata();
expect(stateStore.getState().connection.serverDiagnostics).toMatchObject({
probes: { models: { status: "ok", summary: "1 model" } },
mcpServers: [{ name: "github", startupStatus: "ready" }],
toolInventory: { checkedAt: 1 },
});
});
it("keeps MCP startup notifications out of shared metadata", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const cache = { current: serverMetadataFixture() as SharedServerMetadata | null };
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(cache),
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
isStaleResourceContextError: () => false,
});
await actions.applyAppServerResourceEvent({
type: "mcp-startup-status-updated",
name: "github",
status: "ready",
message: null,
});
expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toMatchObject([{ name: "github", startupStatus: "ready" }]);
expect(cache.current?.serverDiagnostics.mcpServers).toEqual([]);
});
it("ignores stale shared app-server metadata refreshes without applying state", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const stale = new Error("stale");
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshAppServerMetadata: vi.fn().mockRejectedValue(stale),
isStaleResourceContextError: (error) => error === stale,
});
await expect(actions.refreshAppServerMetadata()).resolves.toBeNull();
expect(stateStore.getState().connection.availableModels).toEqual([]);
expect(stateStore.getState().connection.runtimeConfig).toBeNull();
});
it("does not apply stale skill refreshes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const stale = new Error("stale");
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshSkills: vi.fn().mockRejectedValue(stale),
isStaleResourceContextError: (error) => error === stale,
});
await actions.applyAppServerResourceEvent({ type: "skills-changed" });
expect(stateStore.getState().connection.availableSkills).toEqual([]);
});
it("applies authoritative skills refreshed after a change event", async () => {
let state = chatStateFixture();
const previousSkills = [skillFixture("writer")];
state = chatStateWith(state, { connection: { availableSkills: previousSkills } });
const stateStore = createChatStateStore(state);
const refreshed = serverMetadataFixture({
availableSkills: [skillFixture("editor")],
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("skills", "1 skill", 1)),
});
const refreshSkills = vi.fn().mockResolvedValue(refreshed);
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshSkills,
});
await actions.applyAppServerResourceEvent({ type: "skills-changed" });
expect(refreshSkills).toHaveBeenCalledOnce();
expect(stateStore.getState().connection.availableSkills.map((skill) => skill.name)).toEqual(["editor"]);
});
it("publishes refreshed rate limits from update notifications", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } });
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
refreshRateLimits: vi.fn().mockResolvedValue(serverMetadataFixture({ rateLimit })),
});
await actions.applyAppServerResourceEvent({ type: "rate-limits-updated" });
expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } });
});
});
describe("server diagnostics actions", () => {
it("reuses refreshed app-server metadata for deferred diagnostics", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const refreshedMetadata = serverMetadataFixture({
availableSkills: [skillFixture("writer")],
serverDiagnostics: diagnosticsWithProbe(
diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 models", 1)),
diagnosticProbeOk("skills", "1 skills", 1),
),
});
const cache = { current: null as SharedServerMetadata | null };
const metadataCache = metadataCacheHost(cache);
const metadata = createServerMetadataActions({
stateStore,
...metadataCache,
refreshAppServerMetadata: vi.fn().mockImplementation(async () => {
cache.current = refreshedMetadata;
return refreshedMetadata;
}),
isStaleResourceContextError: () => false,
});
const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot());
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics },
...metadataCache,
});
await metadata.refreshAppServerMetadata();
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
expect(readServerDiagnostics).toHaveBeenCalledWith(
expect.objectContaining({
appServerMetadataSnapshot: true,
cachedSkills: [skillFixture("writer")],
cachedSkillsProbe: expect.objectContaining({ status: "ok", summary: "1 skills" }),
}),
);
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "1 models",
});
});
it("uses metadata diagnostics as the default resource probe source", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const metadataCache = metadataCacheHost({
current: serverMetadataFixture({
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "cached models", 1)),
}),
});
const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot());
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics },
...metadataCache,
});
await diagnostics.refreshServerDiagnostics();
expect(readServerDiagnostics).toHaveBeenCalledWith(
expect.objectContaining({
appServerMetadataSnapshot: false,
forceResourceProbes: false,
}),
);
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "cached models",
});
});
it("can force resource probes for explicit health checks", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const readServerDiagnostics = vi.fn().mockResolvedValue(
serverDiagnosticsSnapshot({
resourceProbes: [diagnosticProbeOk("models", "1 models", 1), diagnosticProbeOk("rateLimits", "available", 1)],
}),
);
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics },
...metadataCacheHost(),
});
await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true });
expect(readServerDiagnostics).toHaveBeenCalledWith(expect.objectContaining({ forceResourceProbes: true }));
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "1 models",
});
});
it("does not apply diagnostic probes when the transport returns no snapshot", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics: vi.fn().mockResolvedValue(null) },
appServerMetadataSnapshot: () => null,
});
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
expect(stateStore.getState().connection.serverDiagnostics.probes.mcpServers.status).toBe("unknown");
});
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 metadataCache = metadataCacheHost({ current: serverMetadataFixture() });
const metadata = createServerMetadataActions({
stateStore,
...metadataCache,
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
isStaleResourceContextError: () => false,
});
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: {
readServerDiagnostics: vi.fn().mockResolvedValue(
serverDiagnosticsSnapshot({
mcpServerStatuses: [mcpServerStatus()],
}),
),
},
...metadataCache,
});
await metadata.applyAppServerResourceEvent({
type: "mcp-startup-status-updated",
name: "github",
status: "ready",
message: null,
});
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
const sections = toolInventoryDiagnosticSections(stateStore.getState().connection.serverDiagnostics);
const toolProviderRows = sections.find((section) => section.title === "Tool providers")?.rows ?? [];
expect(sections.map((section) => section.title)).toEqual(["Plugins", "Tool providers", "Skills"]);
expect(toolProviderRows.map((row) => `${row.label}: ${row.value}`)).toEqual(["github: MCP server, ready, auth oAuth, 1 tool"]);
});
it("drops a diagnostics result when its active thread is no longer current", async () => {
const pending = deferred<ReturnType<typeof serverDiagnosticsSnapshot>>();
const stateStore = createChatStateStore(chatStateFixture({ activeThread: { id: "thread-1" } }));
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics: vi.fn(() => pending.promise) },
...metadataCacheHost(),
});
const refreshing = diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
stateStore.dispatch({ type: "active-thread/cleared" });
pending.resolve(serverDiagnosticsSnapshot({ mcpServerStatuses: [mcpServerStatus()] }));
await refreshing;
expect(stateStore.getState().connection.serverDiagnostics.toolInventory).toBeNull();
expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toEqual([]);
});
it("drops a diagnostics result after its connection scope is invalidated", async () => {
const pending = deferred<ReturnType<typeof serverDiagnosticsSnapshot>>();
const stateStore = createChatStateStore(chatStateFixture({ activeThread: { id: "thread-1" } }));
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: { readServerDiagnostics: vi.fn(() => pending.promise) },
...metadataCacheHost(),
});
const refreshing = diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
diagnostics.invalidate();
pending.resolve(serverDiagnosticsSnapshot({ mcpServerStatuses: [mcpServerStatus()] }));
await refreshing;
expect(stateStore.getState().connection.serverDiagnostics.toolInventory).toBeNull();
expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toEqual([]);
});
it("removes MCP providers missing from the latest thread-scoped inventory", async () => {
let initialDiagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), {
name: "github",
startupStatus: "ready",
authStatus: null,
toolCount: null,
message: null,
});
initialDiagnostics = upsertMcpServerDiagnostic(initialDiagnostics, {
name: "removed-provider",
startupStatus: "ready",
authStatus: null,
toolCount: null,
message: null,
});
const stateStore = createChatStateStore(
chatStateFixture({ activeThread: { id: "thread-1" }, connection: { serverDiagnostics: initialDiagnostics } }),
);
const diagnostics = createServerDiagnosticsActions({
stateStore,
diagnosticsTransport: {
readServerDiagnostics: vi.fn().mockResolvedValue(serverDiagnosticsSnapshot({ mcpServerStatuses: [mcpServerStatus()] })),
},
...metadataCacheHost(),
});
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
expect(stateStore.getState().connection.serverDiagnostics.mcpServers.map((server) => server.name)).toEqual(["github"]);
});
});
function serverDiagnosticsSnapshot(
overrides: { resourceProbes?: DiagnosticProbeResult[]; mcpServerStatuses?: McpServerStatusSummary[] | null } = {},
) {
return {
resourceProbes: overrides.resourceProbes ?? [],
toolInventory: {
inventory: toolInventory(),
probes: [
diagnosticProbeOk("plugins", "0 plugins", 1),
diagnosticProbeOk("mcpServers", "0 servers", 1),
diagnosticProbeOk("skills", "0 skills", 1),
],
mcpServerStatuses: overrides.mcpServerStatuses ?? [],
},
};
}
function toolInventory(): ToolInventorySnapshot {
return {
checkedAt: 1,
plugins: [],
pluginMarketplaceErrors: [],
pluginsError: null,
mcpServers: [],
mcpDiagnostics: [],
mcpError: null,
skills: [],
skillsError: null,
};
}
function skillFixture(name: string): SkillMetadata {
return {
name,
description: "",
path: `/skills/${name}`,
enabled: true,
};
}
function rateLimitFixture(overrides: Partial<RateLimitSnapshot> = {}): RateLimitSnapshot {
return {
limitId: "codex",
limitName: "Codex",
primary: null,
secondary: null,
individualLimit: null,
rateLimitReachedType: null,
...overrides,
};
}
function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
return {
runtimeConfig: emptyRuntimeConfigSnapshot(),
availableSkills: [],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
...overrides,
};
}
function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { current: null }): {
appServerMetadataSnapshot: () => SharedServerMetadata | null;
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
refreshSkills: () => Promise<SharedServerMetadata | null>;
refreshRateLimits: () => Promise<SharedServerMetadata | null>;
isStaleResourceContextError: (error: unknown) => boolean;
} {
return {
appServerMetadataSnapshot: () => cache.current,
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
refreshSkills: vi.fn().mockResolvedValue(null),
refreshRateLimits: vi.fn().mockResolvedValue(null),
isStaleResourceContextError: () => false,
};
}
function mcpServerStatus(): McpServerStatusSummary {
return {
name: "github",
authStatus: "oAuth",
toolCount: 1,
resourceCount: 0,
resourceTemplateCount: 0,
};
}