refactor: move extension-owned tests to extensions

This commit is contained in:
Peter Steinberger
2026-03-27 21:36:46 +00:00
parent d506eea076
commit 992b30604d
209 changed files with 4828 additions and 3173 deletions

View File

@@ -1,56 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
type PackageManifest = {
dependencies?: Record<string, string>;
};
function readJson<T>(relativePath: string): T {
const absolutePath = path.resolve(process.cwd(), relativePath);
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T;
}
describe("bundled plugin runtime dependencies", () => {
function expectPluginOwnsRuntimeDep(pluginPath: string, dependencyName: string) {
const rootManifest = readJson<PackageManifest>("package.json");
const pluginManifest = readJson<PackageManifest>(pluginPath);
const pluginSpec = pluginManifest.dependencies?.[dependencyName];
const rootSpec = rootManifest.dependencies?.[dependencyName];
expect(pluginSpec).toBeTruthy();
expect(rootSpec).toBeUndefined();
}
it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk");
});
it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => {
expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
});
it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/discord/package.json", "@buape/carbon");
});
it("keeps bundled Slack runtime deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/slack/package.json", "@slack/bolt");
});
it("keeps bundled Telegram runtime deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy");
});
it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => {
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys");
});
it("keeps WhatsApp image helper deps plugin-local so bundled builds resolve Baileys peers", () => {
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "jimp");
});
it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/discord/package.json", "https-proxy-agent");
});
});

View File

@@ -1,85 +1,22 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js";
import { resolveBundledWebSearchPluginId } from "./bundled-web-search-provider-ids.js";
import {
listBundledWebSearchProviders,
resolveBundledWebSearchPluginIds,
} from "./bundled-web-search.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
describe("bundled web search metadata", () => {
function toComparableEntry(params: {
pluginId: string;
provider: {
id: string;
label: string;
hint: string;
envVars: string[];
placeholder: string;
signupUrl: string;
docsUrl?: string;
autoDetectOrder?: number;
requiresCredential?: boolean;
credentialPath: string;
inactiveSecretPaths?: string[];
getConfiguredCredentialValue?: unknown;
setConfiguredCredentialValue?: unknown;
applySelectionConfig?: unknown;
resolveRuntimeMetadata?: unknown;
};
}) {
return {
pluginId: params.pluginId,
id: params.provider.id,
label: params.provider.label,
hint: params.provider.hint,
envVars: params.provider.envVars,
placeholder: params.provider.placeholder,
signupUrl: params.provider.signupUrl,
docsUrl: params.provider.docsUrl,
autoDetectOrder: params.provider.autoDetectOrder,
requiresCredential: params.provider.requiresCredential,
credentialPath: params.provider.credentialPath,
inactiveSecretPaths: params.provider.inactiveSecretPaths,
hasConfiguredCredentialAccessors:
typeof params.provider.getConfiguredCredentialValue === "function" &&
typeof params.provider.setConfiguredCredentialValue === "function",
hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function",
hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function",
};
}
function sortComparableEntries<
T extends {
autoDetectOrder?: number;
id: string;
pluginId: string;
},
>(entries: T[]): T[] {
return [...entries].toSorted((left, right) => {
const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
return (
leftOrder - rightOrder ||
left.id.localeCompare(right.id) ||
left.pluginId.localeCompare(right.pluginId)
);
});
}
it("keeps bundled web search compat ids aligned with bundled manifests", () => {
expect(resolveBundledWebSearchPluginIds({})).toEqual([
"brave",
"duckduckgo",
"exa",
"firecrawl",
"google",
"moonshot",
"perplexity",
"tavily",
"xai",
]);
const bundledWebSearchPluginIds = loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
expect(resolveBundledWebSearchPluginIds({})).toEqual(bundledWebSearchPluginIds);
});
it("keeps bundled web search fast-path ids aligned with the registry", () => {
@@ -90,138 +27,4 @@ describe("bundled web search metadata", () => {
.toSorted((left, right) => left.localeCompare(right)),
);
});
it("keeps bundled web search provider-to-plugin ids aligned with bundled contracts", () => {
expect(resolveBundledWebSearchPluginId("brave")).toBe("brave");
expect(resolveBundledWebSearchPluginId("duckduckgo")).toBe("duckduckgo");
expect(resolveBundledWebSearchPluginId("exa")).toBe("exa");
expect(resolveBundledWebSearchPluginId("firecrawl")).toBe("firecrawl");
expect(resolveBundledWebSearchPluginId("gemini")).toBe("google");
expect(resolveBundledWebSearchPluginId("kimi")).toBe("moonshot");
expect(resolveBundledWebSearchPluginId("perplexity")).toBe("perplexity");
expect(resolveBundledWebSearchPluginId("tavily")).toBe("tavily");
expect(resolveBundledWebSearchPluginId("grok")).toBe("xai");
});
it("keeps bundled provider metadata aligned with bundled plugin contracts", async () => {
const fastPathProviders = listBundledWebSearchProviders();
const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
expect(
sortComparableEntries(
fastPathProviders.map((provider) =>
toComparableEntry({
pluginId: provider.pluginId,
provider,
}),
),
),
).toEqual(
sortComparableEntries(
bundledProviderEntries.map(({ pluginId, ...provider }) =>
toComparableEntry({
pluginId,
provider,
}),
),
),
);
for (const fastPathProvider of fastPathProviders) {
const bundledEntry = bundledProviderEntries.find(
(entry) => entry.pluginId === fastPathProvider.pluginId && entry.id === fastPathProvider.id,
);
expect(bundledEntry).toBeDefined();
const contractProvider = bundledEntry!;
const fastSearchConfig: Record<string, unknown> = {};
const contractSearchConfig: Record<string, unknown> = {};
fastPathProvider.setCredentialValue(fastSearchConfig, "test-key");
contractProvider.setCredentialValue(contractSearchConfig, "test-key");
expect(fastSearchConfig).toEqual(contractSearchConfig);
expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual(
contractProvider.getCredentialValue(contractSearchConfig),
);
const fastConfig = {} as OpenClawConfig;
const contractConfig = {} as OpenClawConfig;
fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key");
contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key");
expect(fastConfig).toEqual(contractConfig);
expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual(
contractProvider.getConfiguredCredentialValue?.(contractConfig),
);
if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) {
expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual(
contractProvider.applySelectionConfig?.({} as OpenClawConfig),
);
}
if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) {
const metadataCases = [
{
searchConfig: fastSearchConfig,
resolvedCredential: {
value: "pplx-test",
source: "secretRef" as const,
fallbackEnvVar: undefined,
},
},
{
searchConfig: fastSearchConfig,
resolvedCredential: {
value: undefined,
source: "env" as const,
fallbackEnvVar: "OPENROUTER_API_KEY",
},
},
{
searchConfig: {
...fastSearchConfig,
perplexity: {
...(fastSearchConfig.perplexity as Record<string, unknown> | undefined),
model: "custom-model",
},
},
resolvedCredential: {
value: "pplx-test",
source: "secretRef" as const,
fallbackEnvVar: undefined,
},
},
];
for (const testCase of metadataCases) {
expect(
await fastPathProvider.resolveRuntimeMetadata?.({
config: fastConfig,
searchConfig: testCase.searchConfig,
runtimeMetadata: {
diagnostics: [],
providerSource: "configured",
},
resolvedCredential: testCase.resolvedCredential,
}),
).toEqual(
await contractProvider.resolveRuntimeMetadata?.({
config: contractConfig,
searchConfig: testCase.searchConfig,
runtimeMetadata: {
diagnostics: [],
providerSource: "configured",
},
resolvedCredential: testCase.resolvedCredential,
}),
);
}
}
}
});
});

View File

@@ -16,12 +16,12 @@ import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js";
describe("resolveGatewayStartupPluginIds", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["discord"]);
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["demo-channel"]);
loadPluginManifestRegistry.mockReset().mockReturnValue({
plugins: [
{
id: "discord",
channels: ["discord"],
id: "demo-channel",
channels: ["demo-channel"],
origin: "bundled",
enabledByDefault: undefined,
providers: [],
@@ -36,12 +36,12 @@ describe("resolveGatewayStartupPluginIds", () => {
cliBackends: [],
},
{
id: "anthropic",
id: "demo-provider-plugin",
channels: [],
origin: "bundled",
enabledByDefault: undefined,
providers: ["anthropic"],
cliBackends: ["claude-cli"],
providers: ["demo-provider"],
cliBackends: ["demo-cli"],
},
{
id: "diagnostics-otel",
@@ -73,9 +73,9 @@ describe("resolveGatewayStartupPluginIds", () => {
},
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
model: { primary: "demo-cli/demo-model" },
models: {
"claude-cli/claude-sonnet-4-6": {},
"demo-cli/demo-model": {},
},
},
},
@@ -87,7 +87,7 @@ describe("resolveGatewayStartupPluginIds", () => {
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["discord", "anthropic", "diagnostics-otel", "custom-sidecar"]);
).toEqual(["demo-channel", "demo-provider-plugin", "diagnostics-otel", "custom-sidecar"]);
});
it("does not pull default-on bundled non-channel plugins into startup", () => {
@@ -99,14 +99,14 @@ describe("resolveGatewayStartupPluginIds", () => {
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["discord", "custom-sidecar"]);
).toEqual(["demo-channel", "custom-sidecar"]);
});
it("auto-loads bundled plugins referenced by configured provider ids", () => {
const config = {
models: {
providers: {
anthropic: {
"demo-provider": {
baseUrl: "https://example.com",
models: [],
},
@@ -120,6 +120,6 @@ describe("resolveGatewayStartupPluginIds", () => {
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["discord", "anthropic", "custom-sidecar"]);
).toEqual(["demo-channel", "demo-provider-plugin", "custom-sidecar"]);
});
});

View File

@@ -58,8 +58,8 @@ describe("provider auth-choice contract", () => {
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
const pluginFallbackScenarios: ProviderPlugin[] = [
{
id: "github-copilot",
label: "GitHub Copilot",
id: "demo-oauth-provider",
label: "Demo OAuth Provider",
auth: [
{
id: "oauth",
@@ -71,8 +71,8 @@ describe("provider auth-choice contract", () => {
],
},
{
id: "minimax-portal",
label: "MiniMax Portal",
id: "demo-browser-provider",
label: "Demo Browser Provider",
auth: [
{
id: "portal",
@@ -84,8 +84,8 @@ describe("provider auth-choice contract", () => {
],
},
{
id: "modelstudio",
label: "ModelStudio",
id: "demo-api-key-provider",
label: "Demo API Key Provider",
auth: [
{
id: "api-key",
@@ -97,8 +97,8 @@ describe("provider auth-choice contract", () => {
],
},
{
id: "ollama",
label: "Ollama",
id: "demo-local-provider",
label: "Demo Local Provider",
auth: [
{
id: "local",

View File

@@ -1,359 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { createNonExitingRuntime } from "../../runtime.js";
import type {
WizardMultiSelectParams,
WizardPrompter,
WizardProgress,
WizardSelectParams,
} from "../../wizard/prompts.js";
import { registerProviders, requireProvider } from "./testkit.js";
type LoginOpenAICodexOAuth =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
type GithubCopilotLoginCommand =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
(typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
type EnsureAuthProfileStore =
typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore;
type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-login")>();
return {
...actual,
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
function buildPrompter(): WizardPrompter {
const progress: WizardProgress = {
update() {},
stop() {},
};
return {
intro: async () => {},
outro: async () => {},
note: async () => {},
select: async <T>(params: WizardSelectParams<T>) => {
const option = params.options[0];
if (!option) {
throw new Error("missing select option");
}
return option.value;
},
multiselect: async <T>(params: WizardMultiSelectParams<T>) => params.initialValues ?? [],
text: async () => "",
confirm: async () => false,
progress: () => progress,
};
}
function buildAuthContext() {
return {
config: {},
prompter: buildPrompter(),
runtime: createNonExitingRuntime(),
isRemote: false,
openUrl: async () => {},
oauth: {
createVpsAwareHandlers: vi.fn<CreateVpsAwareHandlers>(),
},
};
}
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
function getOpenAICodexProvider() {
return requireProvider(registerProviders(openAIPlugin), "openai-codex");
}
function buildOpenAICodexOAuthResult(params: {
profileId: string;
access: string;
refresh: string;
expires: number;
email?: string;
}) {
return {
profiles: [
{
profileId: params.profileId,
credential: {
type: "oauth" as const,
provider: "openai-codex",
access: params.access,
refresh: params.refresh,
expires: params.expires,
...(params.email ? { email: params.email } : {}),
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
};
}
async function expectOpenAICodexStableFallbackProfile(params: {
access: string;
profileId: string;
}) {
const provider = getOpenAICodexProvider();
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access: params.access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: params.profileId,
access: params.access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
}),
);
}
describe("provider auth contract", () => {
let authStore: AuthProfileStore;
beforeEach(() => {
authStore = { version: 1, profiles: {} };
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockImplementation(() => authStore);
listProfilesForProviderMock.mockReset();
listProfilesForProviderMock.mockImplementation((store, providerId) =>
Object.entries(store.profiles)
.filter(([, credential]) => credential?.provider === providerId)
.map(([profileId]) => profileId),
);
});
afterEach(() => {
loginOpenAICodexOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
clearRuntimeAuthProfileStoreSnapshots();
});
it("keeps OpenAI Codex OAuth auth results provider-owned", async () => {
const provider = getOpenAICodexProvider();
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
email: "user@example.com",
refresh: "refresh-token",
access: "access-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:user@example.com",
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "user@example.com",
}),
);
});
it("backfills OpenAI Codex OAuth email from the JWT profile claim", async () => {
const provider = getOpenAICodexProvider();
const access = createJwt({
"https://api.openai.com/profile": {
email: "jwt-user@example.com",
},
});
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:jwt-user@example.com",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "jwt-user@example.com",
}),
);
});
it("uses a stable fallback id when OpenAI Codex JWT email is missing", async () => {
const access = createJwt({
"https://api.openai.com/auth": {
chatgpt_account_user_id: "user-123__acct-456",
},
});
const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url");
await expectOpenAICodexStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
const access = createJwt({
iss: "https://accounts.openai.com",
sub: "user-abc",
});
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
"base64url",
);
await expectOpenAICodexStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
const access = createJwt({
sub: "user-abc",
});
const expectedStableId = Buffer.from("user-abc").toString("base64url");
await expectOpenAICodexStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("falls back to the default OpenAI Codex profile when JWT parsing yields no identity", async () => {
const provider = getOpenAICodexProvider();
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access: "not-a-jwt-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:default",
access: "not-a-jwt-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
}),
);
});
it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
});
it("keeps GitHub Copilot device auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
authStore.profiles["github-copilot:github"] = {
type: "token" as const,
provider: "github-copilot",
token: "github-device-token",
};
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => true,
});
try {
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith(
{ yes: true, profileId: "github-copilot:github" },
expect.any(Object),
);
expect(result).toEqual({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "github-device-token",
},
},
],
defaultModel: "github-copilot/gpt-4o",
});
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
it("keeps GitHub Copilot auth gated on interactive TTYs", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => false,
});
try {
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
});

View File

@@ -1,104 +0,0 @@
import { beforeAll, beforeEach, describe, it, vi } from "vitest";
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "../../../test/helpers/extensions/provider-registration.js";
import {
expectAugmentedCodexCatalog,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
} from "../provider-runtime.test-support.js";
import type { ProviderPlugin } from "../types.js";
const PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS = 300_000;
type ResolvePluginProviders = typeof import("../providers.runtime.js").resolvePluginProviders;
type ResolveOwningPluginIdsForProvider =
typeof import("../providers.js").resolveOwningPluginIdsForProvider;
type ResolveCatalogHookProviderPluginIds =
typeof import("../providers.js").resolveCatalogHookProviderPluginIds;
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
const resolveOwningPluginIdsForProviderMock = vi.hoisted(() =>
vi.fn<ResolveOwningPluginIdsForProvider>(() => undefined),
);
const resolveCatalogHookProviderPluginIdsMock = vi.hoisted(() =>
vi.fn<ResolveCatalogHookProviderPluginIds>((_) => [] as string[]),
);
vi.mock("../providers.js", () => ({
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
resolveCatalogHookProviderPluginIds: (params: unknown) =>
resolveCatalogHookProviderPluginIdsMock(params as never),
}));
vi.mock("../providers.runtime.js", () => ({
resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never),
}));
let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins;
let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest;
let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression;
let openaiProviders: ProviderPlugin[];
let openaiProvider: ProviderPlugin;
describe("provider catalog contract", { timeout: PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS }, () => {
beforeAll(async () => {
vi.resetModules();
const openaiPlugin = await import("../../../extensions/openai/index.ts");
openaiProviders = registerProviderPlugin({
plugin: openaiPlugin.default,
id: "openai",
name: "OpenAI",
}).providers;
openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
({
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await import("../provider-runtime.js"));
});
beforeEach(() => {
resetProviderRuntimeHookCacheForTest();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
const onlyPluginIds = params?.onlyPluginIds;
if (!onlyPluginIds || onlyPluginIds.length === 0) {
return openaiProviders;
}
return onlyPluginIds.includes("openai") ? openaiProviders : [];
});
resolveOwningPluginIdsForProviderMock.mockReset();
resolveOwningPluginIdsForProviderMock.mockImplementation((params) => {
switch (params.provider) {
case "azure-openai-responses":
case "openai":
case "openai-codex":
return ["openai"];
default:
return undefined;
}
});
resolveCatalogHookProviderPluginIdsMock.mockReset();
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
});
it("keeps codex-only missing-auth hints wired through the provider runtime", () => {
expectCodexMissingAuthHint(
(params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined,
);
});
it("keeps built-in model suppression wired through the provider runtime", () => {
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
});
it("keeps bundled model augmentation wired through the provider runtime", async () => {
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
});
});

View File

@@ -1,572 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { registerProviders, requireProvider } from "./testkit.js";
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
const buildSglangProviderMock = vi.hoisted(() => vi.fn());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog;
let githubCopilotProvider: Awaited<ReturnType<typeof requireProvider>>;
let ollamaProvider: Awaited<ReturnType<typeof requireProvider>>;
let vllmProvider: Awaited<ReturnType<typeof requireProvider>>;
let sglangProvider: Awaited<ReturnType<typeof requireProvider>>;
let minimaxProvider: Awaited<ReturnType<typeof requireProvider>>;
let minimaxPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
let modelStudioProvider: Awaited<ReturnType<typeof requireProvider>>;
let cloudflareAiGatewayProvider: Awaited<ReturnType<typeof requireProvider>>;
function createModelConfig(id: string, name = id): ModelDefinitionConfig {
return {
id,
name,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128_000,
maxTokens: 8_192,
};
}
function setRuntimeAuthStore(store?: AuthProfileStore) {
const resolvedStore = store ?? {
version: 1,
profiles: {},
};
ensureAuthProfileStoreMock.mockReturnValue(resolvedStore);
listProfilesForProviderMock.mockImplementation(
(authStore: AuthProfileStore, providerId: string) =>
Object.entries(authStore.profiles)
.filter(([, credential]) => credential.provider === providerId)
.map(([profileId]) => profileId),
);
}
function setGithubCopilotProfileSnapshot() {
setRuntimeAuthStore({
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
provider: "github-copilot",
token: "profile-token",
},
},
});
}
function runCatalog(params: {
provider: Awaited<ReturnType<typeof requireProvider>>;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined };
resolveProviderAuth?: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
}) {
return runProviderCatalog({
provider: params.provider,
config: {},
env: params.env ?? ({} as NodeJS.ProcessEnv),
resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })),
resolveProviderAuth:
params.resolveProviderAuth ??
((_, options) => ({
apiKey: options?.oauthMarker,
discoveryApiKey: undefined,
mode: options?.oauthMarker ? "oauth" : "none",
source: options?.oauthMarker ? "profile" : "none",
})),
});
}
describe("provider discovery contract", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => {
// Import the direct source module, not the mocked subpath, so bundled
// provider helpers still see the full agent-runtime surface.
const actual = await import("../../plugin-sdk/agent-runtime.ts");
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
vi.doMock("openclaw/plugin-sdk/provider-auth", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-auth");
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
vi.doMock("../../../extensions/github-copilot/token.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/github-copilot/token.js");
return {
...actual,
resolveCopilotApiToken: resolveCopilotApiTokenMock,
};
});
vi.doMock("openclaw/plugin-sdk/provider-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.doMock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => {
const actual = await vi.importActual<object>(
"openclaw/plugin-sdk/self-hosted-provider-setup",
);
return {
...actual,
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
({ runProviderCatalog } = await import("../provider-discovery.js"));
const [
{ default: githubCopilotPlugin },
{ default: ollamaPlugin },
{ default: vllmPlugin },
{ default: sglangPlugin },
{ default: minimaxPlugin },
{ default: modelStudioPlugin },
{ default: cloudflareAiGatewayPlugin },
] = await Promise.all([
import("../../../extensions/github-copilot/index.js"),
import("../../../extensions/ollama/index.js"),
import("../../../extensions/vllm/index.js"),
import("../../../extensions/sglang/index.js"),
import("../../../extensions/minimax/index.js"),
import("../../../extensions/modelstudio/index.js"),
import("../../../extensions/cloudflare-ai-gateway/index.js"),
]);
githubCopilotProvider = requireProvider(
registerProviders(githubCopilotPlugin),
"github-copilot",
);
ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama");
vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm");
sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang");
minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax");
minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal");
modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio");
cloudflareAiGatewayProvider = requireProvider(
registerProviders(cloudflareAiGatewayPlugin),
"cloudflare-ai-gateway",
);
setRuntimeAuthStore();
});
afterEach(() => {
vi.restoreAllMocks();
resolveCopilotApiTokenMock.mockReset();
buildOllamaProviderMock.mockReset();
buildVllmProviderMock.mockReset();
buildSglangProviderMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
});
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
});
it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => {
setGithubCopilotProfileSnapshot();
await expect(
runCatalog({
provider: githubCopilotProvider,
}),
).resolves.toEqual({
provider: {
baseUrl: "https://api.individual.githubcopilot.com",
models: [],
},
});
});
it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => {
resolveCopilotApiTokenMock.mockResolvedValueOnce({
token: "copilot-api-token",
baseUrl: "https://copilot-proxy.example.com",
expiresAt: Date.now() + 60_000,
});
await expect(
runCatalog({
provider: githubCopilotProvider,
env: {
GITHUB_TOKEN: "github-env-token",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
}),
).resolves.toEqual({
provider: {
baseUrl: "https://copilot-proxy.example.com",
models: [],
},
});
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({
githubToken: "github-env-token",
env: expect.objectContaining({
GITHUB_TOKEN: "github-env-token",
}),
});
});
it("keeps Ollama explicit catalog normalization provider-owned", async () => {
await expect(
runProviderCatalog({
provider: ollamaProvider,
config: {
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434/v1/",
models: [createModelConfig("llama3.2")],
},
},
},
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
baseUrl: "http://ollama-host:11434",
api: "ollama",
apiKey: "ollama-local",
models: [createModelConfig("llama3.2")],
},
});
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
});
it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => {
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
await expect(
runProviderCatalog({
provider: ollamaProvider,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
});
it("keeps vLLM self-hosted discovery provider-owned", async () => {
buildVllmProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
});
await expect(
runProviderCatalog({
provider: vllmProvider,
config: {},
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
},
});
expect(buildVllmProviderMock).toHaveBeenCalledWith({
apiKey: "env-vllm-key",
});
});
it("keeps SGLang self-hosted discovery provider-owned", async () => {
buildSglangProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:30000/v1",
api: "openai-completions",
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
});
await expect(
runProviderCatalog({
provider: sglangProvider,
config: {},
env: {
SGLANG_API_KEY: "env-sglang-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "http://127.0.0.1:30000/v1",
api: "openai-completions",
apiKey: "SGLANG_API_KEY",
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
},
});
expect(buildSglangProviderMock).toHaveBeenCalledWith({
apiKey: "env-sglang-key",
});
});
it("keeps MiniMax API catalog provider-owned", async () => {
await expect(
runProviderCatalog({
provider: minimaxProvider,
config: {},
env: {
MINIMAX_API_KEY: "minimax-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "minimax-key" }),
resolveProviderAuth: () => ({
apiKey: "minimax-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
authHeader: true,
apiKey: "minimax-key",
models: expect.arrayContaining([
expect.objectContaining({ id: "MiniMax-M2.7" }),
expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }),
]),
},
});
});
it("keeps MiniMax portal oauth marker fallback provider-owned", async () => {
setRuntimeAuthStore({
version: 1,
profiles: {
"minimax-portal:default": {
type: "oauth",
provider: "minimax-portal",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
});
await expect(
runProviderCatalog({
provider: minimaxPortalProvider,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: "minimax-oauth",
discoveryApiKey: "access-token",
mode: "oauth",
source: "profile",
profileId: "minimax-portal:default",
}),
}),
).resolves.toMatchObject({
provider: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
authHeader: true,
apiKey: "minimax-oauth",
models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]),
},
});
});
it("keeps MiniMax portal explicit base URL override provider-owned", async () => {
await expect(
runProviderCatalog({
provider: minimaxPortalProvider,
config: {
models: {
providers: {
"minimax-portal": {
baseUrl: "https://portal-proxy.example.com/anthropic",
apiKey: "explicit-key",
models: [],
},
},
},
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
baseUrl: "https://portal-proxy.example.com/anthropic",
apiKey: "explicit-key",
},
});
});
it("keeps Model Studio catalog provider-owned", async () => {
await expect(
runProviderCatalog({
provider: modelStudioProvider,
config: {
models: {
providers: {
modelstudio: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
models: [],
},
},
},
},
env: {
MODELSTUDIO_API_KEY: "modelstudio-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }),
resolveProviderAuth: () => ({
apiKey: "modelstudio-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
api: "openai-completions",
apiKey: "modelstudio-key",
models: expect.arrayContaining([
expect.objectContaining({ id: "qwen3.5-plus" }),
expect.objectContaining({ id: "MiniMax-M2.5" }),
]),
},
});
});
it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => {
await expect(
runProviderCatalog({
provider: cloudflareAiGatewayProvider,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
});
it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => {
setRuntimeAuthStore({
version: 1,
profiles: {
"cloudflare-ai-gateway:default": {
type: "api_key",
provider: "cloudflare-ai-gateway",
keyRef: {
source: "env",
provider: "default",
id: "CLOUDFLARE_AI_GATEWAY_API_KEY",
},
metadata: {
accountId: "acc-123",
gatewayId: "gw-456",
},
},
},
});
await expect(
runProviderCatalog({
provider: cloudflareAiGatewayProvider,
config: {},
env: {
CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toEqual({
provider: {
baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic",
api: "anthropic-messages",
apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY",
models: [expect.objectContaining({ id: "claude-sonnet-4-5" })],
},
});
});
});

View File

@@ -15,6 +15,8 @@ function resolveBundledManifestProviderPluginIds() {
);
}
const demoAllowEntry = "demo-allowed";
describe("plugin loader contract", () => {
let providerPluginIds: string[];
let manifestProviderPluginIds: string[];
@@ -31,14 +33,14 @@ describe("plugin loader contract", () => {
compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({
config: {
plugins: {
allow: ["openrouter"],
allow: [demoAllowEntry],
},
},
});
compatConfig = withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: ["openrouter"],
allow: [demoAllowEntry],
},
},
pluginIds: compatPluginIds,
@@ -55,7 +57,7 @@ describe("plugin loader contract", () => {
webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: ["openrouter"],
allow: [demoAllowEntry],
},
},
pluginIds: webSearchPluginIds,

View File

@@ -83,14 +83,14 @@ describe("memory embedding provider registration", () => {
}),
register(api) {
api.registerMemoryEmbeddingProvider({
id: "openai",
id: "demo-embedding",
create: async () => ({ provider: null }),
});
},
});
expect(getRegisteredMemoryEmbeddingProvider("openai")).toEqual({
adapter: expect.objectContaining({ id: "openai" }),
expect(getRegisteredMemoryEmbeddingProvider("demo-embedding")).toEqual({
adapter: expect.objectContaining({ id: "demo-embedding" }),
ownerPluginId: "memory-core",
});
});

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { providerContractLoadError, providerContractRegistry } from "./registry.js";
import { installProviderPluginContractSuite } from "./suites.js";
describe("provider contract registry load", () => {
it("loads bundled providers without import-time registry failure", () => {
expect(providerContractLoadError).toBeUndefined();
expect(providerContractRegistry.length).toBeGreaterThan(0);
});
});
for (const entry of providerContractRegistry) {
describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => {
installProviderPluginContractSuite({
provider: entry.provider,
});
});
}

View File

@@ -12,93 +12,6 @@ import {
const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000;
function findProviderIdsForPlugin(pluginId: string) {
return (
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ??
[]
);
}
function findWebSearchIdsForPlugin(pluginId: string) {
return (
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)
?.webSearchProviderIds ?? []
);
}
function findSpeechProviderIdsForPlugin(pluginId: string) {
return speechProviderContractRegistry
.filter((entry) => entry.pluginId === pluginId)
.map((entry) => entry.provider.id)
.toSorted((left, right) => left.localeCompare(right));
}
function findSpeechProviderForPlugin(pluginId: string) {
const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId);
if (!entry) {
throw new Error(`speech provider contract missing for ${pluginId}`);
}
return entry.provider;
}
function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) {
return mediaUnderstandingProviderContractRegistry
.filter((entry) => entry.pluginId === pluginId)
.map((entry) => entry.provider.id)
.toSorted((left, right) => left.localeCompare(right));
}
function findMediaUnderstandingProviderForPlugin(pluginId: string) {
const entry = mediaUnderstandingProviderContractRegistry.find(
(candidate) => candidate.pluginId === pluginId,
);
if (!entry) {
throw new Error(`media-understanding provider contract missing for ${pluginId}`);
}
return entry.provider;
}
function findImageGenerationProviderIdsForPlugin(pluginId: string) {
return imageGenerationProviderContractRegistry
.filter((entry) => entry.pluginId === pluginId)
.map((entry) => entry.provider.id)
.toSorted((left, right) => left.localeCompare(right));
}
function findImageGenerationProviderForPlugin(pluginId: string) {
const entry = imageGenerationProviderContractRegistry.find(
(candidate) => candidate.pluginId === pluginId,
);
if (!entry) {
throw new Error(`image-generation provider contract missing for ${pluginId}`);
}
return entry.provider;
}
function findRegistrationForPlugin(pluginId: string) {
const entry = pluginRegistrationContractRegistry.find(
(candidate) => candidate.pluginId === pluginId,
);
if (!entry) {
throw new Error(`plugin registration contract missing for ${pluginId}`);
}
return entry;
}
type BundledCapabilityContractKey =
| "speechProviders"
| "mediaUnderstandingProviders"
| "imageGenerationProviders";
function findBundledManifestPluginIdsForContract(key: BundledCapabilityContractKey) {
return loadPluginManifestRegistry({})
.plugins.filter(
(plugin) => plugin.origin === "bundled" && (plugin.contracts?.[key]?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
describe("plugin contract registry", () => {
it("loads bundled non-provider capability registries without import-time failure", () => {
expect(providerContractLoadError).toBeUndefined();
@@ -139,7 +52,13 @@ describe("plugin contract registry", () => {
});
it("covers every bundled speech plugin discovered from manifests", () => {
const bundledSpeechPluginIds = findBundledManifestPluginIdsForContract("speechProviders");
const bundledSpeechPluginIds = loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
expect(
[...new Set(speechProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
@@ -148,30 +67,6 @@ describe("plugin contract registry", () => {
).toEqual(bundledSpeechPluginIds);
});
it("covers every bundled media-understanding plugin discovered from manifests", () => {
const bundledMediaPluginIds = findBundledManifestPluginIdsForContract(
"mediaUnderstandingProviders",
);
expect(
[
...new Set(mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId)),
].toSorted((left, right) => left.localeCompare(right)),
).toEqual(bundledMediaPluginIds);
});
it("covers every bundled image-generation plugin discovered from manifests", () => {
const bundledImagePluginIds = findBundledManifestPluginIdsForContract(
"imageGenerationProviders",
);
expect(
[...new Set(imageGenerationProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
(left, right) => left.localeCompare(right),
),
).toEqual(bundledImagePluginIds);
});
it("covers every bundled web search plugin from the shared resolver", () => {
const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({});
@@ -183,222 +78,8 @@ describe("plugin contract registry", () => {
).toEqual(bundledWebSearchPluginIds);
});
it("keeps Kimi Coding onboarding grouped under Moonshot", () => {
const kimi = loadPluginManifestRegistry({}).plugins.find(
(plugin) => plugin.origin === "bundled" && plugin.id === "kimi",
);
expect(kimi?.providerAuthChoices).toEqual(
expect.arrayContaining([
expect.objectContaining({
choiceId: "kimi-code-api-key",
choiceLabel: "Kimi Code API key (subscription)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi K2.5)",
groupHint: "Kimi K2.5",
}),
]),
);
});
it("does not duplicate bundled image-generation provider ids", () => {
const ids = imageGenerationProviderContractRegistry.map((entry) => entry.provider.id);
expect(ids).toEqual([...new Set(ids)]);
});
it("keeps multi-provider plugin ownership explicit", () => {
expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]);
expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]);
expect(findProviderIdsForPlugin("openai")).toEqual(["openai", "openai-codex"]);
});
it("keeps bundled web search ownership explicit", () => {
expect(findWebSearchIdsForPlugin("brave")).toEqual(["brave"]);
expect(findWebSearchIdsForPlugin("duckduckgo")).toEqual(["duckduckgo"]);
expect(findWebSearchIdsForPlugin("exa")).toEqual(["exa"]);
expect(findWebSearchIdsForPlugin("firecrawl")).toEqual(["firecrawl"]);
expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]);
expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]);
expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]);
expect(findWebSearchIdsForPlugin("tavily")).toEqual(["tavily"]);
expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]);
});
it("keeps bundled speech ownership explicit", () => {
expect(findSpeechProviderIdsForPlugin("elevenlabs")).toEqual(["elevenlabs"]);
expect(findSpeechProviderIdsForPlugin("microsoft")).toEqual(["microsoft"]);
expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]);
});
it("keeps bundled media-understanding ownership explicit", () => {
expect(findMediaUnderstandingProviderIdsForPlugin("anthropic")).toEqual(["anthropic"]);
expect(findMediaUnderstandingProviderIdsForPlugin("google")).toEqual(["google"]);
expect(findMediaUnderstandingProviderIdsForPlugin("minimax")).toEqual([
"minimax",
"minimax-portal",
]);
expect(findMediaUnderstandingProviderIdsForPlugin("mistral")).toEqual(["mistral"]);
expect(findMediaUnderstandingProviderIdsForPlugin("moonshot")).toEqual(["moonshot"]);
expect(findMediaUnderstandingProviderIdsForPlugin("openai")).toEqual([
"openai",
"openai-codex",
]);
expect(findMediaUnderstandingProviderIdsForPlugin("zai")).toEqual(["zai"]);
});
it("keeps bundled image-generation ownership explicit", () => {
expect(findImageGenerationProviderIdsForPlugin("fal")).toEqual(["fal"]);
expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]);
expect(findImageGenerationProviderIdsForPlugin("minimax")).toEqual([
"minimax",
"minimax-portal",
]);
expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]);
});
it("keeps bundled provider and web search tool ownership explicit", () => {
expect(findRegistrationForPlugin("exa")).toMatchObject({
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: ["exa"],
toolNames: [],
});
expect(findRegistrationForPlugin("firecrawl")).toMatchObject({
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: ["firecrawl"],
toolNames: ["firecrawl_search", "firecrawl_scrape"],
});
expect(findRegistrationForPlugin("tavily")).toMatchObject({
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: ["tavily"],
toolNames: ["tavily_search", "tavily_extract"],
});
});
it("tracks speech registrations on bundled provider plugins", () => {
expect(findRegistrationForPlugin("fal")).toMatchObject({
cliBackendIds: [],
providerIds: ["fal"],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: ["fal"],
webSearchProviderIds: [],
});
expect(findRegistrationForPlugin("anthropic")).toMatchObject({
cliBackendIds: ["claude-cli"],
providerIds: ["anthropic"],
speechProviderIds: [],
mediaUnderstandingProviderIds: ["anthropic"],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
});
expect(findRegistrationForPlugin("google")).toMatchObject({
cliBackendIds: ["google-gemini-cli"],
providerIds: ["google", "google-gemini-cli"],
speechProviderIds: [],
mediaUnderstandingProviderIds: ["google"],
imageGenerationProviderIds: ["google"],
webSearchProviderIds: ["gemini"],
});
expect(findRegistrationForPlugin("openai")).toMatchObject({
cliBackendIds: ["codex-cli"],
providerIds: ["openai", "openai-codex"],
speechProviderIds: ["openai"],
mediaUnderstandingProviderIds: ["openai", "openai-codex"],
imageGenerationProviderIds: ["openai"],
});
expect(findRegistrationForPlugin("minimax")).toMatchObject({
cliBackendIds: [],
providerIds: ["minimax", "minimax-portal"],
speechProviderIds: [],
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
imageGenerationProviderIds: ["minimax", "minimax-portal"],
webSearchProviderIds: [],
});
expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({
cliBackendIds: [],
providerIds: [],
speechProviderIds: ["elevenlabs"],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
});
expect(findRegistrationForPlugin("microsoft")).toMatchObject({
cliBackendIds: [],
providerIds: [],
speechProviderIds: ["microsoft"],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
});
});
it("tracks every provider, speech, media, image, or web search plugin in the registration registry", () => {
const expectedPluginIds = [
...new Set([
...pluginRegistrationContractRegistry
.filter((entry) => entry.providerIds.length > 0)
.map((entry) => entry.pluginId),
...speechProviderContractRegistry.map((entry) => entry.pluginId),
...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId),
...imageGenerationProviderContractRegistry.map((entry) => entry.pluginId),
...pluginRegistrationContractRegistry
.filter((entry) => entry.webSearchProviderIds.length > 0)
.map((entry) => entry.pluginId),
]),
].toSorted((left, right) => left.localeCompare(right));
expect(
pluginRegistrationContractRegistry
.map((entry) => entry.pluginId)
.toSorted((left, right) => left.localeCompare(right)),
).toEqual(expectedPluginIds);
});
it("keeps bundled speech voice-list support explicit", () => {
expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function));
expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function));
expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function));
});
it("keeps bundled multi-image support explicit", () => {
expect(findMediaUnderstandingProviderForPlugin("anthropic").describeImages).toEqual(
expect.any(Function),
);
expect(findMediaUnderstandingProviderForPlugin("google").describeImages).toEqual(
expect.any(Function),
);
expect(findMediaUnderstandingProviderForPlugin("minimax").describeImages).toEqual(
expect.any(Function),
);
expect(findMediaUnderstandingProviderForPlugin("moonshot").describeImages).toEqual(
expect.any(Function),
);
expect(findMediaUnderstandingProviderForPlugin("openai").describeImages).toEqual(
expect.any(Function),
);
expect(findMediaUnderstandingProviderForPlugin("zai").describeImages).toEqual(
expect.any(Function),
);
});
it("keeps bundled image-generation support explicit", () => {
expect(findImageGenerationProviderForPlugin("google").generateImage).toEqual(
expect.any(Function),
);
expect(findImageGenerationProviderForPlugin("minimax").generateImage).toEqual(
expect.any(Function),
);
expect(findImageGenerationProviderForPlugin("openai").generateImage).toEqual(
expect.any(Function),
);
});
});

View File

@@ -1,831 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "../../../test/helpers/extensions/provider-registration.js";
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js";
const CONTRACT_SETUP_TIMEOUT_MS = 300_000;
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
const getOAuthProvidersMock = vi.hoisted(() =>
vi.fn(() => [
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, // pragma: allowlist secret
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
]),
);
vi.mock("@mariozechner/pi-ai/oauth", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
"@mariozechner/pi-ai/oauth",
);
return {
...actual,
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
getOAuthProviders: getOAuthProvidersMock,
};
});
vi.mock("../../../extensions/openai/openai-codex-provider.runtime.js", () => ({
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
}));
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
return {
id: overrides.id,
name: overrides.name ?? overrides.id,
api: overrides.api ?? "openai-responses",
provider: overrides.provider ?? "demo",
baseUrl: overrides.baseUrl ?? "https://api.example.com/v1",
reasoning: overrides.reasoning ?? true,
input: overrides.input ?? ["text"],
cost: overrides.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: overrides.contextWindow ?? 200_000,
maxTokens: overrides.maxTokens ?? 8_192,
} satisfies ProviderRuntimeModel;
}
type ProviderRuntimeContractFixture = {
providerIds: string[];
pluginId: string;
name: string;
load: () => Promise<{ default: Parameters<typeof registerProviderPlugin>[0]["plugin"] }>;
};
const PROVIDER_RUNTIME_CONTRACT_FIXTURES: readonly ProviderRuntimeContractFixture[] = [
{
providerIds: ["anthropic"],
pluginId: "anthropic",
name: "Anthropic",
load: async () => await import("../../../extensions/anthropic/index.ts"),
},
{
providerIds: ["github-copilot"],
pluginId: "github-copilot",
name: "GitHub Copilot",
load: async () => await import("../../../extensions/github-copilot/index.ts"),
},
{
providerIds: ["google", "google-gemini-cli"],
pluginId: "google",
name: "Google",
load: async () => await import("../../../extensions/google/index.ts"),
},
{
providerIds: ["openai", "openai-codex"],
pluginId: "openai",
name: "OpenAI",
load: async () => await import("../../../extensions/openai/index.ts"),
},
{
providerIds: ["openrouter"],
pluginId: "openrouter",
name: "OpenRouter",
load: async () => await import("../../../extensions/openrouter/index.ts"),
},
{
providerIds: ["venice"],
pluginId: "venice",
name: "Venice",
load: async () => await import("../../../extensions/venice/index.ts"),
},
{
providerIds: ["xai"],
pluginId: "xai",
name: "xAI",
load: async () => await import("../../../extensions/xai/index.ts"),
},
{
providerIds: ["zai"],
pluginId: "zai",
name: "Z.AI",
load: async () => await import("../../../extensions/zai/index.ts"),
},
] as const;
const providerRuntimeContractProviders = new Map<string, ProviderPlugin>();
function requireProviderContractProvider(providerId: string): ProviderPlugin {
const provider = providerRuntimeContractProviders.get(providerId);
if (!provider) {
throw new Error(`provider runtime contract fixture missing for ${providerId}`);
}
return provider;
}
describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
beforeAll(async () => {
providerRuntimeContractProviders.clear();
const registeredFixtures = await Promise.all(
PROVIDER_RUNTIME_CONTRACT_FIXTURES.map(async (fixture) => {
const plugin = await fixture.load();
return {
fixture,
providers: registerProviderPlugin({
plugin: plugin.default,
id: fixture.pluginId,
name: fixture.name,
}).providers,
};
}),
);
for (const { fixture, providers } of registeredFixtures) {
for (const providerId of fixture.providerIds) {
providerRuntimeContractProviders.set(
providerId,
requireRegisteredProvider(providers, providerId, "provider"),
);
}
}
}, CONTRACT_SETUP_TIMEOUT_MS);
beforeEach(() => {
refreshOpenAICodexTokenMock.mockReset();
getOAuthProvidersMock.mockClear();
}, CONTRACT_SETUP_TIMEOUT_MS);
describe("anthropic", () => {
it(
"owns anthropic 4.6 forward-compat resolution",
() => {
const provider = requireProviderContractProvider("anthropic");
const model = provider.resolveDynamicModel?.({
provider: "anthropic",
modelId: "claude-sonnet-4.6-20260219",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "claude-sonnet-4.5-20260219"
? createModel({
id: id,
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "claude-sonnet-4.6-20260219",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
},
CONTRACT_SETUP_TIMEOUT_MS,
);
it("owns usage auth resolution", async () => {
const provider = requireProviderContractProvider("anthropic");
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "anthropic",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken: async () => ({
token: "anthropic-oauth-token",
}),
}),
).resolves.toEqual({
token: "anthropic-oauth-token",
});
});
it("owns auth doctor hint generation", () => {
const provider = requireProviderContractProvider("anthropic");
const hint = provider.buildAuthDoctorHint?.({
provider: "anthropic",
profileId: "anthropic:default",
config: {
auth: {
profiles: {
"anthropic:default": {
provider: "anthropic",
mode: "oauth",
},
},
},
} as never,
store: {
version: 1,
profiles: {
"anthropic:oauth-user@example.com": {
type: "oauth",
provider: "anthropic",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
},
},
});
expect(hint).toContain("suggested profile: anthropic:oauth-user@example.com");
expect(hint).toContain("openclaw doctor --yes");
});
it("owns usage snapshot fetching", async () => {
const provider = requireProviderContractProvider("anthropic");
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("api.anthropic.com/api/oauth/usage")) {
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
seven_day: { utilization: 35, resets_at: "2026-01-09T01:00:00Z" },
});
}
return makeResponse(404, "not found");
});
await expect(
provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "anthropic",
token: "anthropic-oauth-token",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
}),
).resolves.toEqual({
provider: "anthropic",
displayName: "Claude",
windows: [
{ label: "5h", usedPercent: 20, resetAt: Date.parse("2026-01-07T01:00:00Z") },
{ label: "Week", usedPercent: 35, resetAt: Date.parse("2026-01-09T01:00:00Z") },
],
});
});
});
describe("github-copilot", () => {
it("owns Copilot-specific forward-compat fallbacks", () => {
const provider = requireProviderContractProvider("github-copilot");
const model = provider.resolveDynamicModel?.({
provider: "github-copilot",
modelId: "gpt-5.4",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gpt-5.2-codex"
? createModel({
id,
api: "openai-codex-responses",
provider: "github-copilot",
baseUrl: "https://api.copilot.example",
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4",
provider: "github-copilot",
api: "openai-codex-responses",
});
});
});
describe("google", () => {
it("owns google direct gemini 3.1 forward-compat resolution", () => {
const provider = requireProviderContractProvider("google");
const model = provider.resolveDynamicModel?.({
provider: "google",
modelId: "gemini-3.1-pro-preview",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gemini-3-pro-preview"
? createModel({
id,
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: false,
contextWindow: 1_048_576,
maxTokens: 65_536,
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gemini-3.1-pro-preview",
provider: "google",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: true,
});
});
});
describe("google-gemini-cli", () => {
it("owns gemini cli 3.1 forward-compat resolution", () => {
const provider = requireProviderContractProvider("google-gemini-cli");
const model = provider.resolveDynamicModel?.({
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro-preview",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gemini-3-pro-preview"
? createModel({
id,
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: false,
contextWindow: 1_048_576,
maxTokens: 65_536,
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gemini-3.1-pro-preview",
provider: "google-gemini-cli",
reasoning: true,
});
});
it("owns usage-token parsing", async () => {
const provider = requireProviderContractProvider("google-gemini-cli");
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "google-gemini-cli",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken: async () => ({
token: '{"token":"google-oauth-token"}',
accountId: "google-account",
}),
}),
).resolves.toEqual({
token: "google-oauth-token",
accountId: "google-account",
});
});
it("owns OAuth auth-profile formatting", () => {
const provider = requireProviderContractProvider("google-gemini-cli");
expect(
provider.formatApiKey?.({
type: "oauth",
provider: "google-gemini-cli",
access: "google-oauth-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
projectId: "proj-123",
}),
).toBe('{"token":"google-oauth-token","projectId":"proj-123"}');
});
it("owns usage snapshot fetching", async () => {
const provider = requireProviderContractProvider("google-gemini-cli");
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
return makeResponse(200, {
buckets: [
{ modelId: "gemini-3.1-pro-preview", remainingFraction: 0.4 },
{ modelId: "gemini-3.1-flash-preview", remainingFraction: 0.8 },
],
});
}
return makeResponse(404, "not found");
});
const snapshot = await provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "google-gemini-cli",
token: "google-oauth-token",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(snapshot).toMatchObject({
provider: "google-gemini-cli",
displayName: "Gemini",
});
expect(snapshot?.windows[0]).toEqual({ label: "Pro", usedPercent: 60 });
expect(snapshot?.windows[1]?.label).toBe("Flash");
expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20);
});
});
describe("openai", () => {
it("owns openai gpt-5.4 forward-compat resolution", () => {
const provider = requireProviderContractProvider("openai");
const model = provider.resolveDynamicModel?.({
provider: "openai",
modelId: "gpt-5.4-pro",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gpt-5.2-pro"
? createModel({
id,
provider: "openai",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4-pro",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 1_050_000,
maxTokens: 128_000,
});
});
it("owns openai gpt-5.4 mini forward-compat resolution", () => {
const provider = requireProviderContractProvider("openai");
const model = provider.resolveDynamicModel?.({
provider: "openai",
modelId: "gpt-5.4-mini",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gpt-5-mini"
? createModel({
id,
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
reasoning: true,
contextWindow: 400_000,
maxTokens: 128_000,
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4-mini",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 400_000,
maxTokens: 128_000,
});
});
it("owns direct openai transport normalization", () => {
const provider = requireProviderContractProvider("openai");
expect(
provider.normalizeResolvedModel?.({
provider: "openai",
modelId: "gpt-5.4",
model: createModel({
id: "gpt-5.4",
provider: "openai",
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
contextWindow: 1_050_000,
maxTokens: 128_000,
}),
}),
).toMatchObject({
api: "openai-responses",
});
});
});
describe("xai", () => {
it("owns Grok forward-compat resolution for newer fast models", () => {
const provider = requireProviderContractProvider("xai");
const model = provider.resolveDynamicModel?.({
provider: "xai",
modelId: "grok-4-1-fast-reasoning",
modelRegistry: {
find: () => null,
} as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
});
expect(model).toMatchObject({
id: "grok-4-1-fast-reasoning",
provider: "xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
contextWindow: 2_000_000,
});
});
it("owns xai modern-model matching without accepting multi-agent ids", () => {
const provider = requireProviderContractProvider("xai");
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4-1-fast-reasoning",
} as never),
).toBe(true);
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
} as never),
).toBe(false);
});
it("owns direct xai compat flags on resolved models", () => {
const provider = requireProviderContractProvider("xai");
expect(
provider.normalizeResolvedModel?.({
provider: "xai",
modelId: "grok-4-1-fast",
model: createModel({
id: "grok-4-1-fast",
provider: "xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
}),
} as never),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("openrouter", () => {
it("owns xai downstream compat flags for x-ai routed models", () => {
const provider = requireProviderContractProvider("openrouter");
expect(
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "x-ai/grok-4-1-fast",
model: createModel({
id: "x-ai/grok-4-1-fast",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
}),
}),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("venice", () => {
it("owns xai downstream compat flags for grok-backed Venice models", () => {
const provider = requireProviderContractProvider("venice");
expect(
provider.normalizeResolvedModel?.({
provider: "venice",
modelId: "grok-41-fast",
model: createModel({
id: "grok-41-fast",
provider: "venice",
api: "openai-completions",
baseUrl: "https://api.venice.ai/api/v1",
}),
}),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("openai-codex", () => {
it(
"owns refresh fallback for accountId extraction failures",
{ timeout: CONTRACT_SETUP_TIMEOUT_MS },
async () => {
const provider = requireProviderContractProvider("openai-codex");
const credential = {
type: "oauth" as const,
provider: "openai-codex",
access: "cached-access-token",
refresh: "refresh-token",
expires: Date.now() - 60_000,
};
refreshOpenAICodexTokenMock.mockRejectedValueOnce(
new Error("Failed to extract accountId from token"),
);
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential);
},
);
it("owns forward-compat codex models", () => {
const provider = requireProviderContractProvider("openai-codex");
const model = provider.resolveDynamicModel?.({
provider: "openai-codex",
modelId: "gpt-5.4",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gpt-5.2-codex"
? createModel({
id,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4",
provider: "openai-codex",
api: "openai-codex-responses",
contextWindow: 1_050_000,
maxTokens: 128_000,
});
});
it("owns codex transport defaults", () => {
const provider = requireProviderContractProvider("openai-codex");
expect(
provider.prepareExtraParams?.({
provider: "openai-codex",
modelId: "gpt-5.4",
extraParams: { temperature: 0.2 },
}),
).toEqual({
temperature: 0.2,
transport: "auto",
});
});
it("owns usage snapshot fetching", async () => {
const provider = requireProviderContractProvider("openai-codex");
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
return makeResponse(200, {
rate_limit: {
primary_window: {
used_percent: 12,
limit_window_seconds: 10800,
reset_at: 1_705_000,
},
},
plan_type: "Plus",
});
}
return makeResponse(404, "not found");
});
await expect(
provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "openai-codex",
token: "codex-token",
accountId: "acc-1",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
}),
).resolves.toEqual({
provider: "openai-codex",
displayName: "Codex",
windows: [{ label: "3h", usedPercent: 12, resetAt: 1_705_000_000 }],
plan: "Plus",
});
});
});
describe("zai", () => {
it("owns glm-5 forward-compat resolution", () => {
const provider = requireProviderContractProvider("zai");
const model = provider.resolveDynamicModel?.({
provider: "zai",
modelId: "glm-5",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "glm-4.7"
? createModel({
id,
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: false,
contextWindow: 202_752,
maxTokens: 16_384,
})
: null,
} as never,
});
expect(model).toMatchObject({
id: "glm-5",
provider: "zai",
api: "openai-completions",
reasoning: true,
});
});
it("owns usage auth resolution", async () => {
const provider = requireProviderContractProvider("zai");
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: {
ZAI_API_KEY: "env-zai-token",
} as NodeJS.ProcessEnv,
provider: "zai",
resolveApiKeyFromConfigAndStore: () => "env-zai-token",
resolveOAuthToken: async () => null,
}),
).resolves.toEqual({
token: "env-zai-token",
});
});
it("falls back to legacy pi auth tokens for usage auth", async () => {
const provider = requireProviderContractProvider("zai");
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-contract-"));
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
await fs.writeFile(
path.join(home, ".pi", "agent", "auth.json"),
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`,
"utf8",
);
try {
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: { HOME: home } as NodeJS.ProcessEnv,
provider: "zai",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken: async () => null,
}),
).resolves.toEqual({
token: "legacy-zai-token",
});
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
it("owns usage snapshot fetching", async () => {
const provider = requireProviderContractProvider("zai");
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
return makeResponse(200, {
success: true,
code: 200,
data: {
planName: "Pro",
limits: [
{
type: "TOKENS_LIMIT",
percentage: 25,
unit: 3,
number: 6,
nextResetTime: "2026-01-07T06:00:00Z",
},
],
},
});
}
return makeResponse(404, "not found");
});
await expect(
provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "zai",
token: "env-zai-token",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
}),
).resolves.toEqual({
provider: "zai",
displayName: "z.ai",
windows: [{ label: "Tokens (6h)", usedPercent: 25, resetAt: 1_767_765_600_000 }],
plan: "Pro",
});
});
});
});

View File

@@ -1,11 +1,9 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildElevenLabsSpeechProvider } from "../../../extensions/elevenlabs/test-api.js";
import { buildMicrosoftSpeechProvider } from "../../../extensions/microsoft/test-api.js";
import { buildOpenAISpeechProvider } from "../../../extensions/openai/test-api.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import type { SpeechProviderPlugin } from "../../plugins/types.js";
import { withEnv } from "../../test-utils/env.js";
import * as tts from "../../tts/tts.js";
@@ -124,6 +122,185 @@ function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawC
};
}
function createAudioBuffer(length = 2): Buffer {
return Buffer.from(new Uint8Array(length).fill(1));
}
function resolveBaseUrl(rawValue: unknown, fallback: string): string {
return typeof rawValue === "string" && rawValue.trim() ? rawValue.replace(/\/+$/u, "") : fallback;
}
function buildTestOpenAISpeechProvider(): SpeechProviderPlugin {
return {
id: "openai",
label: "OpenAI",
autoSelectOrder: 10,
resolveConfig: ({ rawConfig }) => {
const config = (rawConfig.openai ?? {}) as Record<string, unknown>;
return {
...config,
baseUrl: resolveBaseUrl(
config.baseUrl ?? process.env.OPENAI_TTS_BASE_URL,
"https://api.openai.com/v1",
),
};
},
parseDirectiveToken: ({ key, value, providerConfig }) => {
if (key === "voice") {
const baseUrl = resolveBaseUrl(
(providerConfig as Record<string, unknown> | undefined)?.baseUrl,
"https://api.openai.com/v1",
);
const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1";
const allowedVoices = new Set([
"alloy",
"ash",
"ballad",
"coral",
"echo",
"sage",
"shimmer",
"verse",
]);
if (isDefaultEndpoint && !allowedVoices.has(value)) {
return { handled: true, warnings: [`invalid OpenAI voice "${value}"`] };
}
return { handled: true, overrides: { voice: value } };
}
if (key === "model") {
const baseUrl = resolveBaseUrl(
(providerConfig as Record<string, unknown> | undefined)?.baseUrl,
"https://api.openai.com/v1",
);
const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1";
const allowedModels = new Set(["tts-1", "tts-1-hd", "gpt-4o-mini-tts"]);
if (isDefaultEndpoint && !allowedModels.has(value)) {
return { handled: true, warnings: [`invalid OpenAI model "${value}"`] };
}
return { handled: true, overrides: { model: value } };
}
return { handled: false };
},
isConfigured: ({ providerConfig }) =>
typeof (providerConfig as Record<string, unknown> | undefined)?.apiKey === "string" ||
typeof process.env.OPENAI_API_KEY === "string",
synthesize: async ({ text, providerConfig, providerOverrides }) => {
const config = providerConfig as Record<string, unknown> | undefined;
await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, {
method: "POST",
body: JSON.stringify({
input: text,
model: providerOverrides?.model ?? config?.model ?? "gpt-4o-mini-tts",
voice: providerOverrides?.voice ?? config?.voice ?? "alloy",
}),
});
return {
audioBuffer: createAudioBuffer(1),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: true,
};
},
synthesizeTelephony: async ({ text, providerConfig }) => {
const config = providerConfig as Record<string, unknown> | undefined;
const configuredModel = typeof config?.model === "string" ? config.model : undefined;
const model = configuredModel ?? "tts-1";
const configuredInstructions =
typeof config?.instructions === "string" ? config.instructions : undefined;
const instructions =
model === "gpt-4o-mini-tts" ? configuredInstructions || undefined : undefined;
await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, {
method: "POST",
body: JSON.stringify({
input: text,
model,
voice: config?.voice ?? "alloy",
instructions,
}),
});
return {
audioBuffer: createAudioBuffer(2),
outputFormat: "mp3",
sampleRate: 24000,
};
},
listVoices: async () => [{ id: "alloy", label: "Alloy" }],
};
}
function buildTestMicrosoftSpeechProvider(): SpeechProviderPlugin {
return {
id: "microsoft",
label: "Microsoft",
aliases: ["edge"],
autoSelectOrder: 30,
resolveConfig: ({ rawConfig }) => {
const edgeConfig = (rawConfig.edge ?? rawConfig.microsoft ?? {}) as Record<string, unknown>;
return {
...edgeConfig,
outputFormat: edgeConfig.outputFormat ?? "audio-24khz-48kbitrate-mono-mp3",
};
},
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: createAudioBuffer(),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: true,
}),
listVoices: async () => [{ id: "edge", label: "Edge" }],
};
}
function buildTestElevenLabsSpeechProvider(): SpeechProviderPlugin {
return {
id: "elevenlabs",
label: "ElevenLabs",
autoSelectOrder: 20,
parseDirectiveToken: ({ key, value, currentOverrides }) => {
if (key === "voiceid") {
return { handled: true, overrides: { voiceId: value } };
}
if (key === "stability") {
return {
handled: true,
overrides: {
voiceSettings: {
...(currentOverrides as { voiceSettings?: Record<string, unknown> } | undefined)
?.voiceSettings,
stability: Number(value),
},
},
};
}
if (key === "speed") {
return {
handled: true,
overrides: {
voiceSettings: {
...(currentOverrides as { voiceSettings?: Record<string, unknown> } | undefined)
?.voiceSettings,
speed: Number(value),
},
},
};
}
return { handled: false };
},
isConfigured: ({ providerConfig }) =>
typeof (providerConfig as Record<string, unknown> | undefined)?.apiKey === "string" ||
typeof process.env.ELEVENLABS_API_KEY === "string" ||
typeof process.env.XI_API_KEY === "string",
synthesize: async () => ({
audioBuffer: createAudioBuffer(),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: true,
}),
listVoices: async () => [{ id: "eleven", label: "Eleven" }],
};
}
describe("tts", () => {
beforeEach(async () => {
({ completeSimple } = await import("@mariozechner/pi-ai"));
@@ -136,9 +313,9 @@ describe("tts", () => {
prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model);
const registry = createEmptyPluginRegistry();
registry.speechProviders = [
{ pluginId: "openai", provider: buildOpenAISpeechProvider(), source: "test" },
{ pluginId: "microsoft", provider: buildMicrosoftSpeechProvider(), source: "test" },
{ pluginId: "elevenlabs", provider: buildElevenLabsSpeechProvider(), source: "test" },
{ pluginId: "openai", provider: buildTestOpenAISpeechProvider(), source: "test" },
{ pluginId: "microsoft", provider: buildTestMicrosoftSpeechProvider(), source: "test" },
{ pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" },
];
setActivePluginRegistry(registry, "tts-test");
vi.clearAllMocks();

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { webSearchProviderContractRegistry } from "./registry.js";
import { installWebSearchProviderContractSuite } from "./suites.js";
describe("web search provider contract registry load", () => {
it("loads bundled web search providers", () => {
expect(webSearchProviderContractRegistry.length).toBeGreaterThan(0);
});
});
for (const entry of webSearchProviderContractRegistry) {
describe(`${entry.pluginId}:${entry.provider.id} web search contract`, () => {
installWebSearchProviderContractSuite({
provider: entry.provider,
credentialValue: entry.credentialValue,
});
});
}

View File

@@ -1,72 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isAtLeast, parseSemver } from "../infra/runtime-guard.js";
import { parseMinHostVersionRequirement } from "./min-host-version.js";
const MIN_HOST_VERSION_BASELINE = "2026.3.22";
const PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION = [
"extensions/bluebubbles/package.json",
"extensions/discord/package.json",
"extensions/feishu/package.json",
"extensions/googlechat/package.json",
"extensions/irc/package.json",
"extensions/line/package.json",
"extensions/matrix/package.json",
"extensions/mattermost/package.json",
"extensions/memory-lancedb/package.json",
"extensions/msteams/package.json",
"extensions/nextcloud-talk/package.json",
"extensions/nostr/package.json",
"extensions/synology-chat/package.json",
"extensions/tlon/package.json",
"extensions/twitch/package.json",
"extensions/voice-call/package.json",
"extensions/whatsapp/package.json",
"extensions/zalo/package.json",
"extensions/zalouser/package.json",
] as const;
type PackageJsonLike = {
openclaw?: {
install?: {
minHostVersion?: string;
};
};
};
describe("install minHostVersion guardrails", () => {
it("requires published plugins that depend on new sdk subpaths to declare a host floor", () => {
const baseline = parseSemver(MIN_HOST_VERSION_BASELINE);
expect(baseline).not.toBeNull();
if (!baseline) {
return;
}
for (const relativePath of PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION) {
const manifest = JSON.parse(
fs.readFileSync(path.resolve(relativePath), "utf-8"),
) as PackageJsonLike;
const requirement = parseMinHostVersionRequirement(
manifest.openclaw?.install?.minHostVersion,
);
expect(
requirement,
`${relativePath} should declare openclaw.install.minHostVersion`,
).not.toBeNull();
if (!requirement) {
continue;
}
const minimum = parseSemver(requirement.minimumLabel);
expect(minimum, `${relativePath} should use a parseable semver floor`).not.toBeNull();
if (!minimum) {
continue;
}
expect(
isAtLeast(minimum, baseline),
`${relativePath} should require at least OpenClaw ${MIN_HOST_VERSION_BASELINE}`,
).toBe(true);
}
});
});

View File

@@ -8,6 +8,8 @@ import { createHookRunner } from "./hooks.js";
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
describe("message_sending hook runner", () => {
const demoChannelCtx = { channelId: "demo-channel" };
it("runMessageSending invokes registered hooks and returns modified content", async () => {
const handler = vi.fn().mockReturnValue({ content: "modified content" });
const registry = createMockPluginRegistry([{ hookName: "message_sending", handler }]);
@@ -15,12 +17,12 @@ describe("message_sending hook runner", () => {
const result = await runner.runMessageSending(
{ to: "user-123", content: "original content" },
{ channelId: "telegram" },
demoChannelCtx,
);
expect(handler).toHaveBeenCalledWith(
{ to: "user-123", content: "original content" },
{ channelId: "telegram" },
demoChannelCtx,
);
expect(result?.content).toBe("modified content");
});
@@ -32,7 +34,7 @@ describe("message_sending hook runner", () => {
const result = await runner.runMessageSending(
{ to: "user-123", content: "blocked" },
{ channelId: "telegram" },
demoChannelCtx,
);
expect(result?.cancel).toBe(true);
@@ -40,6 +42,8 @@ describe("message_sending hook runner", () => {
});
describe("message_sent hook runner", () => {
const demoChannelCtx = { channelId: "demo-channel" };
it("runMessageSent invokes registered hooks with success=true", async () => {
const handler = vi.fn();
const registry = createMockPluginRegistry([{ hookName: "message_sent", handler }]);
@@ -47,12 +51,12 @@ describe("message_sent hook runner", () => {
await runner.runMessageSent(
{ to: "user-123", content: "hello", success: true },
{ channelId: "telegram" },
demoChannelCtx,
);
expect(handler).toHaveBeenCalledWith(
{ to: "user-123", content: "hello", success: true },
{ channelId: "telegram" },
demoChannelCtx,
);
});
@@ -63,12 +67,12 @@ describe("message_sent hook runner", () => {
await runner.runMessageSent(
{ to: "user-123", content: "hello", success: false, error: "timeout" },
{ channelId: "telegram" },
demoChannelCtx,
);
expect(handler).toHaveBeenCalledWith(
{ to: "user-123", content: "hello", success: false, error: "timeout" },
{ channelId: "telegram" },
demoChannelCtx,
);
});
});