mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
851 lines
33 KiB
TypeScript
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 };
|
|
}
|