Files
openclaw/extensions/codex/src/app-server/plugin-metadata-cache.test.ts
Peter Steinberger 9d4610cbd1 fix(codex): settle plugin activation, align plugin/list with codex 0.144, bound discovery per turn (#108311)
* fix(codex): settle plugin activation, align plugin/list with codex 0.144, bound discovery per turn

- plugin/list curated queries omit cwds and marketplaceKinds (0.144 semantics:
  explicit kinds disable the automatic global remote catalog; cwds:[] never
  suppressed it), and the missing-marketplace check recognizes the current
  openai-curated-remote wire name via the shared predicate (#107305)
- curated plugin/list snapshots settle in a process-local metadata cache
  (coalesced, invalidated on install/identity change/restart, 1h freshness
  window matching the app-inventory cache) so a missing marketplace or plugin
  no longer re-runs blocking discovery RPCs on every embedded-Codex turn;
  fail-open local-only responses (upstream warns without a load error when the
  remote catalog fetch fails) are never cached, and workspace-directory
  queries stay live because external activation has no invalidation signal
- the whole plugin-config build shares one bounded startup deadline with
  remaining-budget propagation per RPC and a deny-all apps fallback, so a hung
  plugin/list cannot consume the turn (#107305)
- guarded thread requests (start/resume/fork under the native-config fence)
  must carry a finite timeout or abort signal, closing the unbounded-fence-hold
  window for raw callers (#106719 hardening)

* chore(codex): keep plugin metadata types and deadline builder module-local

Deadline behavior tests exercise the production provider composition instead
of a test-only export.
2026-07-15 06:40:29 -07:00

308 lines
11 KiB
TypeScript

// Codex tests cover plugin metadata cache behavior.
import { describe, expect, it, vi } from "vitest";
import { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin metadata cache", () => {
it("coalesces and reuses the full successful snapshot", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const first = cache.load(params);
const second = cache.load(params);
const response = pluginList("openai-curated-remote", "calendar");
release?.(response);
const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]);
expect(request).toHaveBeenCalledTimes(1);
expect(firstSnapshot).toBe(secondSnapshot);
expect(firstSnapshot.response).toBe(response);
await expect(cache.load(params)).resolves.toBe(firstSnapshot);
expect(request).toHaveBeenCalledTimes(1);
});
it("does not settle snapshots the caller marks uncacheable", async () => {
// Upstream plugin/list fails open for remote catalogs (local-only response,
// empty marketplaceLoadErrors); such a snapshot must not settle negatives.
const cache = new CodexPluginMetadataCache();
const failOpen = pluginList("local-only");
const healthy = pluginList("openai-curated-remote", "calendar");
const request = vi.fn(async () => (request.mock.calls.length > 1 ? healthy : failOpen));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
cacheable: (response: v2.PluginListResponse) =>
response.marketplaces.some((entry) => entry.name === "openai-curated-remote"),
};
await expect(cache.load(params)).resolves.toMatchObject({ response: failOpen });
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({ response: healthy });
expect(cache.read("runtime-a", "curated-global")?.response).toBe(healthy);
expect(request).toHaveBeenCalledTimes(2);
});
it("expires settled snapshots after the freshness window", async () => {
// Upstream refreshes its remote catalog in the background; a settled
// negative must not deny a configured plugin for the process lifetime.
let now = 0;
const cache = new CodexPluginMetadataCache(() => now);
const request = vi.fn(async () => pluginList("openai-curated-remote", "calendar"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await cache.load(params);
await cache.load(params);
expect(request).toHaveBeenCalledTimes(1);
now = 60 * 60 * 1_000 + 1;
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await cache.load(params);
expect(request).toHaveBeenCalledTimes(2);
});
it("keeps query kinds and runtime identities separate", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async (_method: string, params: v2.PluginListParams) =>
pluginList(params.marketplaceKinds ? "workspace-directory" : "openai-curated-remote"),
);
await cache.load({
appCacheKey: "runtime-a",
queryKind: "curated-global",
requestParams: {},
request,
});
await cache.load({
appCacheKey: "runtime-a",
queryKind: "workspace-directory",
requestParams: { cwds: [], marketplaceKinds: ["workspace-directory"] },
request,
});
await cache.load({
appCacheKey: "runtime-b",
queryKind: "curated-global",
requestParams: {},
request,
});
expect(request).toHaveBeenCalledTimes(3);
});
it("does not cache failed requests", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi
.fn<() => Promise<v2.PluginListResponse>>()
.mockRejectedValueOnce(new Error("catalog unavailable"))
.mockResolvedValueOnce(pluginList("openai-curated-remote"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await expect(cache.load(params)).rejects.toThrow("catalog unavailable");
await expect(cache.load(params)).resolves.toMatchObject({
response: { marketplaces: [{ name: "openai-curated-remote" }] },
});
expect(request).toHaveBeenCalledTimes(2);
});
it("does not cache responses with marketplace load errors", async () => {
const cache = new CodexPluginMetadataCache();
const incomplete = pluginList("openai-curated-remote");
incomplete.marketplaceLoadErrors = [{ message: "catalog unavailable" }];
const request = vi
.fn<() => Promise<v2.PluginListResponse>>()
.mockResolvedValueOnce(incomplete)
.mockResolvedValueOnce(pluginList("openai-curated-remote", "calendar"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await expect(cache.load(params)).resolves.toMatchObject({ response: incomplete });
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({
response: { marketplaces: [{ plugins: [{ id: "calendar" }] }] },
});
expect(request).toHaveBeenCalledTimes(2);
});
it("starts a fresh load after invalidation while an older load is pending", async () => {
const cache = new CodexPluginMetadataCache();
const releases: Array<(response: v2.PluginListResponse) => void> = [];
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
releases.push(resolve);
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const beforeInstall = cache.load(params);
await vi.waitFor(() => expect(releases).toHaveLength(1));
cache.invalidate("runtime-a");
const afterInstall = cache.load(params);
await vi.waitFor(() => expect(releases).toHaveLength(2));
const current = pluginList("openai-curated-remote", "calendar");
releases[1]?.(current);
await expect(afterInstall).resolves.toMatchObject({ response: current });
releases[0]?.(pluginList("openai-curated-remote"));
await expect(beforeInstall).resolves.toBeDefined();
expect(cache.read("runtime-a", "curated-global")?.response).toBe(current);
expect(request).toHaveBeenCalledTimes(2);
});
it("retries a joined load with the caller's request after the owner fails", async () => {
const cache = new CodexPluginMetadataCache();
let rejectOwner: ((error: Error) => void) | undefined;
const ownerRequest = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((_resolve, reject) => {
rejectOwner = reject;
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
};
const owner = cache.load({ ...params, request: ownerRequest });
const ownerResult = owner.catch((error: unknown) => error);
await vi.waitFor(() => expect(rejectOwner).toBeTypeOf("function"));
const joiningRequest = vi.fn(async () => pluginList("openai-curated-remote", "calendar"));
const joining = cache.load({ ...params, request: joiningRequest });
rejectOwner?.(new Error("owner cancelled"));
await expect(ownerResult).resolves.toBeInstanceOf(Error);
await expect(joining).resolves.toMatchObject({
response: { marketplaces: [{ plugins: [{ id: "calendar" }] }] },
});
expect(ownerRequest).toHaveBeenCalledTimes(1);
expect(joiningRequest).toHaveBeenCalledTimes(1);
});
it("reuses a successful workspace snapshot for the process lifetime", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => pluginList("workspace-directory"));
const params = {
appCacheKey: "runtime-a",
queryKind: "workspace-directory" as const,
requestParams: {
cwds: [],
marketplaceKinds: ["workspace-directory"],
} satisfies v2.PluginListParams,
request,
};
const first = await cache.load(params);
await expect(cache.load(params)).resolves.toBe(first);
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps an unrelated runtime load cacheable across invalidation", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
);
const params = {
appCacheKey: "runtime-b",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const pending = cache.load(params);
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
cache.invalidate("runtime-a");
const response = pluginList("openai-curated-remote", "calendar");
release?.(response);
await pending;
await expect(cache.load(params)).resolves.toMatchObject({ response });
expect(request).toHaveBeenCalledTimes(1);
});
it("invalidates one runtime and clear resets all snapshots", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => pluginList("openai-curated-remote"));
const load = (appCacheKey: string) =>
cache.load({
appCacheKey,
queryKind: "curated-global",
requestParams: {},
request,
});
await load("runtime-a");
await load("runtime-b");
cache.invalidate("runtime-a");
await load("runtime-a");
await load("runtime-b");
expect(request).toHaveBeenCalledTimes(3);
cache.clear();
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
expect(cache.read("runtime-b", "curated-global")).toBeUndefined();
});
});
function pluginList(marketplaceName: string, pluginId?: string): v2.PluginListResponse {
return {
marketplaces: [
{
name: marketplaceName,
path: null,
interface: null,
plugins: pluginId
? [
{
id: pluginId,
name: pluginId,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
]
: [],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}