murashit_codex-panel/tests/features/chat/app-server/actions/actions.test.ts
2026-07-02 15:13:17 +09:00

851 lines
33 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AppServerClient, ClientResponseByMethod } from "../../../../../src/app-server/connection/client";
import type { ClientRequestParams } from "../../../../../src/app-server/connection/rpc-messages";
import {
type CatalogModel,
type CatalogSkillMetadata,
modelMetadataFromCatalogModels,
} from "../../../../../src/app-server/protocol/catalog";
import { threadFromThreadRecord } from "../../../../../src/app-server/protocol/thread";
import { StaleAppServerSharedQueryContextError } from "../../../../../src/app-server/query/shared-queries";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import type { RateLimitSnapshot } from "../../../../../src/domain/runtime/metrics";
import {
createServerDiagnostics,
diagnosticProbeError,
diagnosticProbeOk,
diagnosticsWithProbe,
} from "../../../../../src/domain/server/diagnostics";
import type { McpServerStatus } from "../../../../../src/domain/server/mcp-status";
import type { SharedServerMetadata } from "../../../../../src/domain/server/metadata";
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 { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/tool-inventory-diagnostic-sections";
import { chatStateFixture, chatStateWith } from "../../support/state";
type ThreadStartResponse = ClientResponseByMethod["thread/start"];
describe("chat app-server actions", () => {
it("publishes newly started threads before the first turn completes", async () => {
let state = chatStateFixture();
const existing = threadFixture("existing");
state = chatStateWith(state, { threadList: { listedThreads: [threadFromThreadRecord(existing)] } });
const stateStore = createChatStateStore(state);
const started = threadFixture("started");
const optimistic = threadFromThreadRecord({ ...started, preview: "first prompt" });
const existingThread = threadFromThreadRecord(existing);
const applyThreadCatalogEvent = vi.fn();
const syncThreadGoal = vi.fn();
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: null,
approvalsReviewer: null,
reasoningEffort: null,
}),
);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent,
syncThreadGoal,
});
await actions.startThread("first prompt");
expect(stateStore.getState().threadList.listedThreads).toEqual([optimistic, existingThread]);
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-started", thread: optimistic });
expect(syncThreadGoal).toHaveBeenCalledWith("started");
});
it("keeps empty-panel runtime reservations when starting the first thread", async () => {
const stateStore = createChatStateStore(chatStateFixture());
stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-5.5" });
stateStore.dispatch({ type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
stateStore.dispatch({ type: "runtime/reasoning-effort-requested", effort: "high" });
stateStore.dispatch({ type: "runtime/fast-mode-requested", fastMode: "enabled" });
stateStore.dispatch({ type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" });
const started = threadFixture("started");
const startThread = vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: "fast",
approvalsReviewer: null,
reasoningEffort: null,
});
const client = startThreadClient(startThread);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent: vi.fn(),
syncThreadGoal: vi.fn(),
});
await actions.startThread("first prompt");
expect(startThread).toHaveBeenCalledWith({
cwd: "/vault",
permissions: ":workspace",
serviceName: "codex-panel",
serviceTier: "fast",
});
expect(stateStore.getState().runtime.active.model).toBe("gpt-5");
expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
expect(stateStore.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" });
expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
expect(stateStore.getState().runtime.pending.collaborationMode).toEqual(setCollaborationModeIntent("plan"));
});
it("can skip newly started thread goal sync when the caller sets the first goal", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const started = threadFixture("started");
const syncThreadGoal = vi.fn();
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: null,
approvalsReviewer: null,
reasoningEffort: null,
}),
);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent: vi.fn(),
syncThreadGoal,
});
await actions.startThread("first goal", { syncGoal: false });
expect(syncThreadGoal).not.toHaveBeenCalled();
});
it("starts threads with service tier from explicit effective config", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "flex" } } });
const stateStore = createChatStateStore(state);
const started = threadFixture("started");
const startThread = vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: "flex",
approvalsReviewer: null,
reasoningEffort: null,
});
const client = startThreadClient(startThread);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent: vi.fn(),
syncThreadGoal: vi.fn(),
});
await actions.startThread();
expect(startThread).toHaveBeenCalledWith({
cwd: "/vault",
serviceName: "codex-panel",
serviceTier: "flex",
});
});
it("starts threads with permission profile from explicit config", async () => {
let state = chatStateFixture();
state = chatStateWith(state, {
connection: {
runtimeConfig: {
...emptyRuntimeConfigSnapshot(),
startupPermissions: {
...emptyRuntimeConfigSnapshot().startupPermissions,
activePermissionProfile: { id: ":workspace", extends: null },
},
},
},
});
const stateStore = createChatStateStore(state);
const started = threadFixture("started");
const startThread = vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: null,
approvalsReviewer: null,
reasoningEffort: null,
});
const client = startThreadClient(startThread);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent: vi.fn(),
syncThreadGoal: vi.fn(),
});
await actions.startThread();
expect(startThread).toHaveBeenCalledWith({
cwd: "/vault",
permissions: ":workspace",
serviceName: "codex-panel",
});
});
it("keeps app-server preview when newly started threads already have one", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const started = threadFixture("started", { preview: "server preview" });
const applyThreadCatalogEvent = vi.fn();
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: null,
approvalsReviewer: null,
reasoningEffort: null,
}),
);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent,
syncThreadGoal: () => undefined,
});
await actions.startThread("local preview");
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-started", thread: threadFromThreadRecord(started) });
});
it("does not apply newly started threads after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const start = deferred<ThreadStartResponse>();
const firstClient = startThreadClient(vi.fn().mockReturnValue(start.promise));
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const applyThreadCatalogEvent = vi.fn();
const syncThreadGoal = vi.fn();
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent,
syncThreadGoal,
});
const starting = actions.startThread("local preview");
currentClient = secondClient;
start.resolve({
thread: threadFixture("stale-started"),
cwd: "/vault",
model: "gpt-5",
modelProvider: "openai",
serviceTier: null,
approvalPolicy: "on-request",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalsReviewer: "user",
activePermissionProfile: null,
sandbox: { type: "readOnly", networkAccess: false },
reasoningEffort: null,
multiAgentMode: "explicitRequestOnly",
});
await expect(starting).resolves.toBeNull();
expect(stateStore.getState().activeThread.id).toBeNull();
expect(stateStore.getState().threadList.listedThreads).toEqual([]);
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
expect(syncThreadGoal).not.toHaveBeenCalled();
});
it("reuses cached app-server metadata for deferred diagnostics", async () => {
const state = chatStateFixture();
const stateStore = createChatStateStore(state);
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
const refreshedMetadata = serverMetadataFixture({
availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-5.1")]),
availableSkills: [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }],
rateLimit: rateLimitFixture(),
serverDiagnostics: diagnosticsWithProbe(
diagnosticsWithProbe(
diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 models", 1)),
diagnosticProbeOk("skills", "1 skills", 1),
),
diagnosticProbeOk("rateLimits", "available", 1),
),
});
const refreshAppServerMetadata = vi.fn<() => Promise<SharedServerMetadata | null>>().mockResolvedValue(refreshedMetadata);
const client = requestClient({
"mcpServerStatus/list": listMcpServerStatus,
});
const metadataCache = metadataCacheHost({ current: null });
const metadata = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCache,
refreshAppServerMetadata: async () => {
const next = await refreshAppServerMetadata();
metadataCache.updateAppServerMetadata(() => next);
return next;
},
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCache,
});
await metadata.refreshAppServerMetadata();
await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "1 models",
});
expect(stateStore.getState().connection.serverDiagnostics.probes.skills).toMatchObject({
status: "ok",
summary: "1 skills",
});
expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits).toMatchObject({
status: "ok",
summary: "available",
});
});
it("ignores stale shared app-server metadata refreshes without applying state", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const metadata = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => null,
...metadataCacheHost({ current: null }),
refreshAppServerMetadata: async () => {
throw new StaleAppServerSharedQueryContextError();
},
});
await expect(metadata.refreshAppServerMetadata()).resolves.toBeNull();
expect(stateStore.getState().connection.availableModels).toEqual([]);
expect(stateStore.getState().connection.runtimeConfig).toBeNull();
});
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 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 listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
const client = requestClient({
"model/list": listModels,
"skills/list": listSkills,
"account/rateLimits/read": readAccountRateLimits,
"mcpServerStatus/list": listMcpServerStatus,
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCache,
});
await diagnostics.refreshServerDiagnostics();
expect(listModels).not.toHaveBeenCalled();
expect(listSkills).not.toHaveBeenCalled();
expect(readAccountRateLimits).not.toHaveBeenCalled();
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "cached models",
});
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
});
it("can force resource probes for explicit health checks", async () => {
const stateStore = createChatStateStore(chatStateFixture());
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 client = requestClient({
"model/list": listModels,
"skills/list": listSkills,
"account/rateLimits/read": readAccountRateLimits,
"mcpServerStatus/list": vi.fn().mockResolvedValue({ data: [] }),
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCacheHost(),
});
await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true });
expect(listModels).toHaveBeenCalledWith({ includeHidden: false, limit: 100 });
expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: false });
expect(readAccountRateLimits).toHaveBeenCalledWith(undefined);
expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({
status: "ok",
summary: "1 models",
});
});
it("does not apply or publish diagnostic probes after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const mcpStatusRefresh = deferred<{ data: ReturnType<typeof mcpServerStatus>[] }>();
const listMcpServerStatus = vi.fn().mockReturnValue(mcpStatusRefresh.promise);
const firstClient = requestClient({
"mcpServerStatus/list": listMcpServerStatus,
});
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => currentClient,
appServerMetadataSnapshot: () => null,
updateAppServerMetadata,
});
const refreshing = diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true });
currentClient = secondClient;
mcpStatusRefresh.resolve({ data: [mcpServerStatus()] });
await refreshing;
expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 });
expect(stateStore.getState().connection.serverDiagnostics.probes.mcpServers.status).toBe("unknown");
expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toEqual([]);
expect(updateAppServerMetadata).not.toHaveBeenCalled();
});
it("does not apply or publish app-server metadata when the client changes before refresh completes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const refreshAppServerMetadata = vi.fn().mockResolvedValue(null);
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => ({}) as AppServerClient,
...metadataCacheHost(),
refreshAppServerMetadata,
});
const refreshing = actions.refreshAppServerMetadata();
await expect(refreshing).resolves.toBeNull();
expect(stateStore.getState().connection.availableModels).toEqual([]);
expect(stateStore.getState().connection.availableSkills).toEqual([]);
});
it("keeps query-cached models visible when metadata model refresh fails", async () => {
const state = chatStateFixture();
const stateStore = createChatStateStore(state);
const metadata = serverMetadataFixture({
availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-cached")]),
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("models", new Error("offline"), 1)),
});
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => ({}) as AppServerClient,
...metadataCacheHost({ current: metadata }),
refreshAppServerMetadata: async () => metadata,
});
await actions.refreshAppServerMetadata();
expect(stateStore.getState().connection.availableModels.map((model) => model.model)).toEqual(["gpt-cached"]);
expect(stateStore.getState().connection.serverDiagnostics.probes.models.status).toBe("failed");
});
it("does not use chat state as a second model source when metadata model refresh fails", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { connection: { availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-state-only")]) } });
const stateStore = createChatStateStore(state);
const metadata = serverMetadataFixture({
availableModels: [],
serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("models", new Error("offline"), 1)),
});
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => ({}) as AppServerClient,
...metadataCacheHost({ current: metadata }),
refreshAppServerMetadata: async () => metadata,
});
await actions.refreshAppServerMetadata();
expect(stateStore.getState().connection.availableModels).toEqual([]);
expect(stateStore.getState().connection.serverDiagnostics.probes.models.status).toBe("failed");
});
it("does not apply or publish refreshed skills after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const skillRefresh = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>();
const listSkills = vi.fn().mockReturnValue(skillRefresh.promise);
const firstClient = requestClient({ "skills/list": listSkills });
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => currentClient,
appServerMetadataSnapshot: () => null,
updateAppServerMetadata,
refreshAppServerMetadata: async () => null,
});
const refreshing = actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true });
currentClient = secondClient;
skillRefresh.resolve({ data: [{ skills: [skillFixture("stale-skill")] }] });
await refreshing;
expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: true });
expect(stateStore.getState().connection.availableSkills).toEqual([]);
expect(stateStore.getState().connection.serverDiagnostics.probes.skills.status).toBe("unknown");
expect(updateAppServerMetadata).not.toHaveBeenCalled();
});
it("keeps previous skills when sparse skill refresh fails", async () => {
let state = chatStateFixture();
const previousSkills = [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }];
state = chatStateWith(state, { connection: { availableSkills: previousSkills } });
const stateStore = createChatStateStore(state);
const listSkills = vi.fn().mockRejectedValue(new Error("offline"));
const client = requestClient({ "skills/list": listSkills });
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCacheHost(),
refreshAppServerMetadata: async () => null,
});
await actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true });
expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: true });
expect(stateStore.getState().connection.availableSkills).toEqual(previousSkills);
expect(stateStore.getState().connection.serverDiagnostics.probes.skills).toMatchObject({ status: "failed" });
});
it("publishes refreshed rate limits from sparse update notifications", async () => {
const state = chatStateFixture();
const stateStore = createChatStateStore(state);
const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } });
const cachedMetadata = { current: serverMetadataFixture() as SharedServerMetadata | null };
const client = requestClient({
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }),
});
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCacheHost(cachedMetadata),
refreshAppServerMetadata: async () => null,
});
await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true });
expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } });
expect(cachedMetadata.current?.rateLimit).toStrictEqual(rateLimit);
});
it("keeps the previous rate limit snapshot when sparse update refresh fails", async () => {
let state = chatStateFixture();
const previousRateLimit = rateLimitFixture({
limitName: "Codex",
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
});
state = chatStateWith(state, { connection: { rateLimit: previousRateLimit } });
const stateStore = createChatStateStore(state);
const client = requestClient({
"account/rateLimits/read": vi.fn().mockRejectedValue(new Error("offline")),
});
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCacheHost(),
refreshAppServerMetadata: async () => null,
});
await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true });
expect(stateStore.getState().connection.rateLimit).toBe(previousRateLimit);
expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits).toMatchObject({ status: "failed" });
});
it("does not apply or publish sparse rate limit refreshes after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const rateLimitRefresh = deferred<{ rateLimits: RateLimitSnapshot; rateLimitsByLimitId: null }>();
const firstClient = requestClient({
"account/rateLimits/read": vi.fn().mockReturnValue(rateLimitRefresh.promise),
});
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
const actions = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => currentClient,
appServerMetadataSnapshot: () => null,
updateAppServerMetadata,
refreshAppServerMetadata: async () => null,
});
const refreshing = actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true });
currentClient = secondClient;
rateLimitRefresh.resolve({
rateLimits: rateLimitFixture({ primary: { usedPercent: 88, windowDurationMins: 300, resetsAt: null } }),
rateLimitsByLimitId: null,
});
await refreshing;
expect(stateStore.getState().connection.rateLimit).toBeNull();
expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits.status).toBe("unknown");
expect(updateAppServerMetadata).not.toHaveBeenCalled();
});
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 = requestClient({
"app/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
"plugin/installed": vi.fn().mockResolvedValue({ marketplaces: [], marketplaceLoadErrors: [] }),
"mcpServerStatus/list": listMcpServerStatus,
"skills/list": vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
});
const metadataCache = metadataCacheHost({ current: serverMetadataFixture() });
const metadata = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCache,
refreshAppServerMetadata: async () => null,
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...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, 0 resources",
]);
expect(listMcpServerStatus).toHaveBeenCalledWith({
detail: "toolsAndAuthOnly",
limit: 100,
threadId: "thread-1",
});
});
});
function requestClient(handlers: Record<string, ReturnType<typeof vi.fn>> = {}): AppServerClient {
const methodHandlers: Record<string, (params: unknown) => Promise<unknown>> = {
"skills/list": async (params) => ({ data: [{ cwd: (params as { cwds: string[] }).cwds[0] ?? "", skills: [] }] }),
"app/list": async () => ({ data: [], nextCursor: null }),
"plugin/installed": async () => ({ marketplaces: [], marketplaceLoadErrors: [] }),
"plugin/read": async () => ({ plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] } }),
...handlers,
};
return {
request: vi.fn((method: string, params: unknown) => {
const handler = methodHandlers[method];
if (!handler) throw new Error(`Unexpected app-server request: ${method}`);
return handler(params);
}),
} as unknown as AppServerClient;
}
function startThreadClient(startThread: ReturnType<typeof vi.fn>): AppServerClient {
return {
request: vi.fn((method: string, params: ClientRequestParams<"thread/start">) => {
if (method !== "thread/start") throw new Error(`Unexpected app-server request: ${method}`);
if (!params.cwd) throw new Error("Expected thread/start cwd.");
return (startThread as unknown as (params: ClientRequestParams<"thread/start">) => Promise<ThreadStartResponse>)(params);
}),
} as unknown as AppServerClient;
}
function threadFixture(id: string, overrides: Partial<ThreadStartResponse["thread"]> = {}): ThreadStartResponse["thread"] {
return {
id,
sessionId: id,
forkedFromId: null,
parentThreadId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 0,
updatedAt: 0,
recencyAt: null,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "test",
source: "unknown",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
...overrides,
};
}
function modelFixture(model: string): CatalogModel {
return {
id: model,
model,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: model,
description: "",
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: "medium",
inputModalities: ["text"],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
isDefault: false,
};
}
function skillFixture(name: string): CatalogSkillMetadata {
return {
name,
description: "",
path: `/skills/${name}`,
scope: "repo",
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(),
availableModels: [],
availableSkills: [],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
...overrides,
};
}
function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { current: null }): {
appServerMetadataSnapshot: () => SharedServerMetadata | null;
updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null;
} {
return {
appServerMetadataSnapshot: () => cache.current,
updateAppServerMetadata: (updater) => {
const next = updater(cache.current);
cache.current = next;
return next;
},
};
}
function mcpServerStatus(): McpServerStatus {
return {
name: "github",
serverInfo: null,
tools: {
search_issues: { name: "search_issues", inputSchema: {} },
},
resources: [],
resourceTemplates: [],
authStatus: "oAuth",
} as McpServerStatus;
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((settle) => {
resolve = settle;
});
return { promise, resolve };
}