test: merge provider contract wrappers

This commit is contained in:
Peter Steinberger
2026-04-18 01:31:56 +01:00
parent 6b99917d4e
commit 27f34f0491
24 changed files with 138 additions and 137 deletions

View File

@@ -18,14 +18,34 @@ import {
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import type { DiscordComponentMessageSpec } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
import { sendDiscordComponentMessage } from "./send.components.js";
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
type DiscordSendRuntime = typeof import("./send.js");
type DiscordSendFn = DiscordSendRuntime["sendMessageDiscord"];
type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage;
let discordSendRuntimePromise: Promise<DiscordSendRuntime> | undefined;
let discordComponentSendPromise: Promise<DiscordComponentSendFn> | undefined;
async function loadDiscordSendRuntime(): Promise<DiscordSendRuntime> {
discordSendRuntimePromise ??= import("./send.js");
return await discordSendRuntimePromise;
}
async function sendDiscordComponentMessageLazy(
...args: Parameters<DiscordComponentSendFn>
): ReturnType<DiscordComponentSendFn> {
discordComponentSendPromise ??= import("./send.components.js").then(
(module) => module.sendDiscordComponentMessage,
);
return await (
await discordComponentSendPromise
)(...args);
}
function hasApprovalChannelData(payload: { channelData?: unknown }): boolean {
const channelData = payload.channelData;
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
@@ -93,6 +113,7 @@ async function maybeSendDiscordWebhookText(params: {
if (!threadId) {
return null;
}
const { getThreadBindingManager } = await import("./monitor/thread-bindings.js");
const manager = getThreadBindingManager(params.accountId ?? undefined);
if (!manager) {
return null;
@@ -105,6 +126,7 @@ async function maybeSendDiscordWebhookText(params: {
identity: params.identity,
binding,
});
const { sendWebhookMessageDiscord } = await loadDiscordSendRuntime();
const result = await sendWebhookMessageDiscord(params.text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
@@ -134,7 +156,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
| { components?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
discordData?.components ??
(payload.interactive
? (await import("./shared-interactive.js")).buildDiscordInteractiveComponents(
payload.interactive,
)
: undefined);
const componentSpec = rawComponentSpec
? rawComponentSpec.text
? rawComponentSpec
@@ -154,7 +181,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(ctx.deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload);
const result = await sendPayloadMediaSequenceOrFallback({
@@ -162,7 +190,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
mediaUrls,
fallbackResult: { messageId: "", channelId: target },
sendNoMedia: async () =>
await sendDiscordComponentMessage(target, componentSpec, {
await sendDiscordComponentMessageLazy(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
@@ -170,7 +198,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
}),
send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, {
return await sendDiscordComponentMessageLazy(target, componentSpec, {
mediaUrl,
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
@@ -213,7 +241,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
}
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
replyTo: replyToId ?? undefined,
@@ -236,7 +265,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
mediaUrl,
@@ -249,7 +279,9 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) =>
await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
await (
await loadDiscordSendRuntime()
).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,

View File

@@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const REPO_ROOT = resolve(SRC_ROOT, "..");
const sourceCache = new Map<string, string>();
const tsFilesCache = new Map<string, string[]>();
const ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS = new Set([
"src/media-generation/provider-capabilities.contract.test.ts",
@@ -39,6 +41,11 @@ type FileFilter = {
};
function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[] {
const cacheKey = `${rootRelativePath}:${filter.excludeTests ? "exclude-tests" : ""}:${filter.testOnly ? "test-only" : ""}`;
const cached = tsFilesCache.get(cacheKey);
if (cached) {
return cached;
}
const root = resolve(REPO_ROOT, rootRelativePath);
const files: string[] = [];
@@ -64,7 +71,19 @@ function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[
}
walk(root);
return files.toSorted();
const sorted = files.toSorted();
tsFilesCache.set(cacheKey, sorted);
return sorted;
}
function readRepoSource(file: string): string {
const cached = sourceCache.get(file);
if (cached !== undefined) {
return cached;
}
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
sourceCache.set(file, source);
return source;
}
describe("plugin contract boundary invariants", () => {
@@ -74,8 +93,7 @@ describe("plugin contract boundary invariants", () => {
if (ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS.has(file)) {
return false;
}
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
return source.includes("contracts/inventory/bundled-capability-metadata");
return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata");
});
expect(offenders).toEqual([]);
});
@@ -83,8 +101,7 @@ describe("plugin contract boundary invariants", () => {
it("keeps the bundled contract inventory out of non-test runtime code", () => {
const files = listTsFiles("src", { excludeTests: true });
const offenders = files.filter((file) => {
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
return source.includes("contracts/inventory/bundled-capability-metadata");
return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata");
});
expect(offenders).toEqual([]);
});
@@ -95,7 +112,7 @@ describe("plugin contract boundary invariants", () => {
if (ALLOWED_EXTENSION_PATH_STRING_TESTS.has(file)) {
return false;
}
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
const source = readRepoSource(file);
return (
/from\s+["'][^"']*extensions\/.+(?:api|runtime-api|test-api)\.js["']/u.test(source) ||
/vi\.(?:mock|doMock)\(\s*["'][^"']*extensions\/.+["']/u.test(source) ||
@@ -111,8 +128,7 @@ describe("plugin contract boundary invariants", () => {
if (ALLOWED_CONTRACT_BUNDLED_PATH_HELPERS.has(file)) {
return false;
}
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
return source.includes("test/helpers/bundled-plugin-paths");
return readRepoSource(file).includes("test/helpers/bundled-plugin-paths");
});
expect(offenders).toEqual([]);
});
@@ -123,8 +139,7 @@ describe("plugin contract boundary invariants", () => {
if (ALLOWED_CHANNEL_BUNDLED_METADATA_CONSUMERS.has(file)) {
return false;
}
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
return source.includes("plugins/bundled-plugin-metadata");
return readRepoSource(file).includes("plugins/bundled-plugin-metadata");
});
expect(offenders).toEqual([]);
});
@@ -135,7 +150,7 @@ describe("plugin contract boundary invariants", () => {
...listTsFiles("src/channels", { excludeTests: true }),
].toSorted();
const offenders = files.filter((file) => {
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
const source = readRepoSource(file);
return /extensions\/\$\{|\.\.\/\.\.\/\.\.\/\.\.\/extensions\//u.test(source);
});
expect(offenders).toEqual([]);

View File

@@ -49,6 +49,7 @@ const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const REPO_ROOT = resolve(SRC_ROOT, "..");
const PLUGIN_SDK_DIR = resolve(SRC_ROOT, "plugin-sdk");
const sourceCache = new Map<string, string>();
const repoTsFilesCache = new Map<string, string[]>();
const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const;
const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier);
@@ -226,8 +227,12 @@ function expectNamedExportParity(params: BrowserHelperExportParityContract) {
}
function listRepoTsFiles(dir: string): string[] {
const cached = repoTsFilesCache.get(dir);
if (cached) {
return cached;
}
const entries = readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const files = entries.flatMap((entry) => {
const absolute = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "dist" || entry.name === "node_modules") {
@@ -240,6 +245,8 @@ function listRepoTsFiles(dir: string): string[] {
}
return absolute.endsWith(".ts") ? [absolute] : [];
});
repoTsFilesCache.set(dir, files);
return files;
}
function findRepoFilesContaining(params: {
@@ -253,7 +260,7 @@ function findRepoFilesContaining(params: {
.flatMap((root) => listRepoTsFiles(root))
.filter((file) => !excluded.has(file))
.filter((file) => !(params.excludeFilesMatching ?? []).some((pattern) => pattern.test(file)))
.filter((file) => params.pattern.test(readFileSync(file, "utf8")))
.filter((file) => params.pattern.test(readCachedSource(file)))
.map((file) => file.slice(REPO_ROOT.length + 1))
.toSorted();
}

View File

@@ -1,3 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
describeProviderContracts("anthropic");

View File

@@ -1,3 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
describeProviderContracts("fal");

View File

@@ -1,5 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeProviderContracts("google");
describeWebSearchProviderContracts("google");

View File

@@ -1,3 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
describeProviderContracts("minimax");

View File

@@ -1,5 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeProviderContracts("moonshot");
describeWebSearchProviderContracts("moonshot");

View File

@@ -1,3 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
describeProviderContracts("openai");

View File

@@ -1,3 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
describeProviderContracts("openrouter");

View File

@@ -1,5 +0,0 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeProviderContracts("xai");
describeWebSearchProviderContracts("xai");

View File

@@ -0,0 +1,29 @@
import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js";
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
for (const providerId of [
"anthropic",
"fal",
"google",
"minimax",
"moonshot",
"openai",
"openrouter",
"xai",
] as const) {
describeProviderContracts(providerId);
}
for (const providerId of [
"brave",
"duckduckgo",
"exa",
"firecrawl",
"google",
"moonshot",
"perplexity",
"tavily",
"xai",
] as const) {
describeWebSearchProviderContracts(providerId);
}

View File

@@ -64,13 +64,6 @@ describe("runtime import side-effect contracts", () => {
getActivePluginChannelRegistryVersion.mockClear().mockReturnValue(1);
});
it("keeps config/markdown-tables cold on import", async () => {
mockChannelRegistry();
await import("../../config/markdown-tables.js");
expectNoChannelRegistryDuringImport("src/config/markdown-tables.ts");
});
it("keeps markdown table defaults lazy and memoized after import", async () => {
mockChannelRegistry();
const markdownTables = await import("../../config/markdown-tables.js");
@@ -85,52 +78,26 @@ describe("runtime import side-effect contracts", () => {
expect(listChannelPlugins).toHaveBeenCalledTimes(1);
});
it("keeps plugins/runtime/runtime-channel cold on import", async () => {
it("keeps hot runtime imports cold", async () => {
mockChannelRegistry();
await import("../runtime/runtime-channel.js");
expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-channel.ts");
});
it("keeps plugin-sdk/approval-handler-adapter-runtime cold on import", async () => {
mockChannelRegistry();
await import("../../plugin-sdk/approval-handler-adapter-runtime.js");
expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-handler-adapter-runtime.ts");
});
it("keeps plugin-sdk/approval-gateway-runtime cold on import", async () => {
mockChannelRegistry();
await import("../../plugin-sdk/approval-gateway-runtime.js");
expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-gateway-runtime.ts");
});
it("keeps plugins/runtime/runtime-system cold on import", async () => {
mockChannelRegistry();
await import("../runtime/runtime-system.js");
expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-system.ts");
});
it("keeps web-search/runtime cold on import", async () => {
mockChannelRegistry();
await import("../../web-search/runtime.js");
expectNoChannelRegistryDuringImport("src/web-search/runtime.ts");
});
it("keeps web-fetch/runtime cold on import", async () => {
mockChannelRegistry();
await import("../../web-fetch/runtime.js");
expectNoChannelRegistryDuringImport("src/web-fetch/runtime.ts");
});
it("keeps plugins/runtime/index cold on import", async () => {
mockChannelRegistry();
await import("../runtime/index.js");
expectNoChannelRegistryDuringImport("src/plugins/runtime/index.ts");
for (const [moduleId, importModule] of [
["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
["src/plugins/runtime/runtime-channel.ts", () => import("../runtime/runtime-channel.js")],
[
"src/plugin-sdk/approval-handler-adapter-runtime.ts",
() => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
],
[
"src/plugin-sdk/approval-gateway-runtime.ts",
() => import("../../plugin-sdk/approval-gateway-runtime.js"),
],
["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
["src/plugins/runtime/index.ts", () => import("../runtime/index.js")],
] as const) {
await importModule();
expectNoChannelRegistryDuringImport(moduleId);
}
});
});

View File

@@ -1,3 +0,0 @@
import { describeTtsAutoApplyContract } from "../../../test/helpers/plugins/tts-contract-suites.js";
describeTtsAutoApplyContract();

View File

@@ -1,3 +0,0 @@
import { describeTtsConfigContract } from "../../../test/helpers/plugins/tts-contract-suites.js";
describeTtsConfigContract();

View File

@@ -0,0 +1,11 @@
import {
describeTtsAutoApplyContract,
describeTtsConfigContract,
describeTtsProviderRuntimeContract,
describeTtsSummarizationContract,
} from "../../../test/helpers/plugins/tts-contract-suites.js";
describeTtsAutoApplyContract();
describeTtsConfigContract();
describeTtsProviderRuntimeContract();
describeTtsSummarizationContract();

View File

@@ -1,3 +0,0 @@
import { describeTtsProviderRuntimeContract } from "../../../test/helpers/plugins/tts-contract-suites.js";
describeTtsProviderRuntimeContract();

View File

@@ -1,3 +0,0 @@
import { describeTtsSummarizationContract } from "../../../test/helpers/plugins/tts-contract-suites.js";
describeTtsSummarizationContract();

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("brave");

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("duckduckgo");

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("exa");

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("firecrawl");

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("perplexity");

View File

@@ -1,3 +0,0 @@
import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js";
describeWebSearchProviderContracts("tavily");