refactor: relocate channel contract helpers

This commit is contained in:
Peter Steinberger
2026-04-28 02:13:58 +01:00
parent a66605bf23
commit d35ada2f54
67 changed files with 133 additions and 133 deletions

View File

@@ -1,34 +0,0 @@
# Test Helper Boundary
This directory holds shared channel test helpers used by core and bundled plugin
tests.
This file adds channel-specific rules on top of `test/helpers/AGENTS.md`.
## Bundled Plugin Imports
- Core test helpers in this directory must not hardcode repo-relative imports
into `extensions/**`.
- When a helper needs a bundled plugin public/test surface, go through
`src/test-utils/bundled-plugin-public-surface.ts`.
- Prefer `loadBundledPluginTestApiSync(...)` for eager access to exported test
helpers.
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` when a test needs a
module id for dynamic import or mocking.
- If `vi.mock(...)` hoisting would evaluate the module id too early, use
`vi.doMock(...)` with the resolved module id instead of falling back to a
hardcoded path.
- For contract helpers, prefer minimal in-memory channel/plugin fixtures when
the contract only needs capabilities, session binding hooks, routing metadata,
or outbound payload helpers. Do not load broad `api.ts`, `runtime-api.ts`, or
`test-api.ts` barrels for incidental setup.
- If a bundled plugin parser is the contract under test, load the narrow module
that owns that parser or promote a small public artifact. Avoid pulling a full
extension barrel just to parse a target id.
## Intent
- Keep shared test helpers aligned with the same public/plugin boundary that
production code uses.
- Avoid drift where core test helpers start reaching into bundled plugin private
files by path because it is convenient in one test.

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -1,94 +0,0 @@
import { listBundledChannelPluginIds as listCatalogBundledChannelPluginIds } from "../../../src/channels/plugins/bundled-ids.js";
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import {
listChannelCatalogEntries,
type PluginChannelCatalogEntry,
} from "../../../src/plugins/channel-catalog-registry.js";
import {
loadBundledPluginPublicSurface,
loadBundledPluginPublicSurfaceSync,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
type ChannelPluginApiModule = Record<string, unknown>;
const channelPluginCache = new Map<ChannelId, ChannelPlugin | null>();
const channelPluginPromiseCache = new Map<ChannelId, Promise<ChannelPlugin | null>>();
let channelCatalogEntries: PluginChannelCatalogEntry[] | undefined;
function isChannelPlugin(value: unknown): value is ChannelPlugin {
return (
Boolean(value) &&
typeof value === "object" &&
typeof (value as Partial<ChannelPlugin>).id === "string" &&
Boolean((value as Partial<ChannelPlugin>).meta) &&
Boolean((value as Partial<ChannelPlugin>).config)
);
}
export function listBundledChannelPluginIds(): readonly ChannelId[] {
return listCatalogBundledChannelPluginIds() as ChannelId[];
}
export function getBundledChannelCatalogEntry(
id: ChannelId,
): PluginChannelCatalogEntry | undefined {
channelCatalogEntries ??= listChannelCatalogEntries({ origin: "bundled" });
return channelCatalogEntries.find((entry) => entry.pluginId === id || entry.channel.id === id);
}
export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
if (channelPluginCache.has(id)) {
return channelPluginCache.get(id) ?? undefined;
}
const loaded = loadBundledPluginPublicSurfaceSync<ChannelPluginApiModule>({
pluginId: id,
artifactBasename: "channel-plugin-api.js",
});
const plugin = Object.values(loaded).find(isChannelPlugin) ?? null;
channelPluginCache.set(id, plugin);
return plugin ?? undefined;
}
export async function getBundledChannelPluginAsync(
id: ChannelId,
): Promise<ChannelPlugin | undefined> {
if (channelPluginCache.has(id)) {
return channelPluginCache.get(id) ?? undefined;
}
const cachedPromise = channelPluginPromiseCache.get(id);
if (cachedPromise) {
return (await cachedPromise) ?? undefined;
}
const loading = loadBundledPluginPublicSurface<ChannelPluginApiModule>({
pluginId: id,
artifactBasename: "channel-plugin-api.js",
})
.then((loaded) => {
const plugin = Object.values(loaded).find(isChannelPlugin) ?? null;
channelPluginCache.set(id, plugin);
return plugin;
})
.finally(() => {
channelPluginPromiseCache.delete(id);
});
channelPluginPromiseCache.set(id, loading);
return (await loading) ?? undefined;
}
export function listBundledChannelPlugins(): readonly ChannelPlugin[] {
return listBundledChannelPluginIds().flatMap((id) => {
const plugin = getBundledChannelPlugin(id);
return plugin ? [plugin] : [];
});
}
export async function listBundledChannelPluginsAsync(): Promise<readonly ChannelPlugin[]> {
const plugins = await Promise.all(
listBundledChannelPluginIds().map((id) => getBundledChannelPluginAsync(id)),
);
return plugins.filter((plugin): plugin is ChannelPlugin => Boolean(plugin));
}

View File

@@ -1,251 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getChannelPluginCatalogEntry,
listChannelPluginCatalogEntries,
} from "../../../src/channels/plugins/catalog.js";
type CatalogEntryMeta = {
id: string;
label: string;
selectionLabel: string;
docsPath: string;
blurb: string;
detailLabel?: string;
aliases?: string[];
};
export function describeChannelCatalogEntryContract(params: {
channelId: string;
npmSpec: string;
alias?: string;
}) {
describe(`${params.channelId} channel catalog contract`, () => {
it("keeps the shipped catalog entry aligned", () => {
const entry = getChannelPluginCatalogEntry(params.channelId);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
if (params.alias) {
expect(entry?.meta.aliases).toContain(params.alias);
}
});
it("appears in the channel catalog listing", () => {
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
expect(ids).toContain(params.channelId);
});
});
}
export function describeBundledMetadataOnlyChannelCatalogContract(params: {
pluginId: string;
packageName: string;
npmSpec: string;
meta: CatalogEntryMeta;
defaultChoice?: string;
}) {
describe(`${params.pluginId} bundled metadata-only channel catalog contract`, () => {
it("includes the bundled metadata-only channel entry when the runtime entrypoint is omitted", () => {
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
const bundledDir = path.join(packageRoot, "dist", "extensions", params.pluginId);
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw" }),
"utf8",
);
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
extensions: ["./index.js"],
channel: params.meta,
install: {
npmSpec: params.npmSpec,
defaultChoice: params.defaultChoice,
},
},
}),
"utf8",
);
fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(bundledDir, "openclaw.plugin.json"),
JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
},
}).find((item) => item.id === params.meta.id);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
expect(entry?.pluginId).toBe(params.pluginId);
});
});
}
export function describeOfficialFallbackChannelCatalogContract(params: {
channelId: string;
npmSpec: string;
meta: CatalogEntryMeta;
packageName: string;
pluginId: string;
externalNpmSpec: string;
externalLabel: string;
}) {
describe(`${params.channelId} official fallback channel catalog contract`, () => {
it("includes shipped official channel catalog entries when bundled metadata is omitted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-"));
const catalogPath = path.join(dir, "channel-catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: params.packageName,
openclaw: {
channel: params.meta,
install: {
npmSpec: params.npmSpec,
defaultChoice: "npm",
},
},
},
],
}),
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
officialCatalogPaths: [catalogPath],
}).find((item) => item.id === params.channelId);
expect(entry?.install.npmSpec).toBe(params.npmSpec);
expect(entry?.pluginId).toBeUndefined();
});
it("lets external catalogs override shipped fallback channel metadata", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
const bundledDir = path.join(dir, "dist", "extensions", params.pluginId);
const officialCatalogPath = path.join(dir, "channel-catalog.json");
const externalCatalogPath = path.join(dir, "catalog.json");
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
channel: {
...params.meta,
label: `${params.meta.label} Bundled`,
selectionLabel: `${params.meta.label} Bundled`,
blurb: "bundled fallback",
},
install: { npmSpec: params.npmSpec },
},
}),
"utf8",
);
fs.writeFileSync(
officialCatalogPath,
JSON.stringify({
entries: [
{
name: params.packageName,
openclaw: {
channel: {
...params.meta,
label: `${params.meta.label} Official`,
selectionLabel: `${params.meta.label} Official`,
blurb: "official fallback",
},
install: { npmSpec: params.npmSpec },
},
},
],
}),
"utf8",
);
fs.writeFileSync(
externalCatalogPath,
JSON.stringify({
entries: [
{
name: params.externalNpmSpec,
openclaw: {
channel: {
...params.meta,
label: params.externalLabel,
selectionLabel: params.externalLabel,
blurb: "external override",
},
install: { npmSpec: params.externalNpmSpec },
},
},
],
}),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
catalogPaths: [externalCatalogPath],
officialCatalogPaths: [officialCatalogPath],
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
},
}).find((item) => item.id === params.channelId);
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
expect(entry?.meta.label).toBe(params.externalLabel);
expect(entry?.pluginId).toBeUndefined();
});
it("surfaces package-name drift in external channel catalog install metadata", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-drifted-catalog-"));
const catalogPath = path.join(dir, "catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: params.packageName,
openclaw: {
channel: params.meta,
install: {
npmSpec: `${params.packageName}-fork@1.2.3`,
expectedIntegrity: "sha512-drifted",
},
},
},
],
}),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
catalogPaths: [catalogPath],
officialCatalogPaths: [],
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
}).find((item) => item.id === params.channelId);
expect(entry?.installSource?.npm).toMatchObject({
packageName: `${params.packageName}-fork`,
expectedPackageName: params.packageName,
});
expect(entry?.installSource?.warnings).toEqual(["npm-spec-package-name-mismatch"]);
});
});
}

View File

@@ -1,420 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listChannelPluginCatalogEntries } from "../../../src/channels/plugins/catalog.js";
function createCatalogEntry(params: {
packageName: string;
channelId: string;
label: string;
blurb: string;
order?: number;
}) {
return {
name: params.packageName,
openclaw: {
channel: {
id: params.channelId,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.channelId}`,
blurb: params.blurb,
...(params.order === undefined ? {} : { order: params.order }),
},
install: {
npmSpec: params.packageName,
},
},
};
}
function writeCatalogFile(catalogPath: string, entry: Record<string, unknown>) {
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [entry],
}),
);
}
function writeDiscoveredChannelPlugin(params: {
stateDir: string;
packageName: string;
channelLabel: string;
pluginId: string;
blurb: string;
}) {
const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: {
extensions: ["./index.js"],
channel: {
id: "demo-channel",
label: params.channelLabel,
selectionLabel: params.channelLabel,
docsPath: "/channels/demo-channel",
blurb: params.blurb,
},
install: {
npmSpec: params.packageName,
},
},
}),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.pluginId,
configSchema: {},
}),
"utf8",
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
}
function expectCatalogIdsContain(params: {
expectedId: string;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
const ids = listChannelPluginCatalogEntries({
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}).map((entry) => entry.id);
expect(ids).toContain(params.expectedId);
}
function findCatalogEntry(params: {
channelId: string;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
return listChannelPluginCatalogEntries({
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}).find((entry) => entry.id === params.channelId);
}
function expectCatalogEntryMatch(params: {
channelId: string;
expected: Record<string, unknown>;
catalogPaths?: string[];
env?: NodeJS.ProcessEnv;
}) {
expect(
findCatalogEntry({
channelId: params.channelId,
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
...(params.env ? { env: params.env } : {}),
}),
).toMatchObject(params.expected);
}
export function describeChannelPluginCatalogEntriesContract() {
describe("channel plugin catalog entries contract", () => {
it.each([
{
name: "includes external catalog entries",
setup: () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-"));
const catalogPath = path.join(dir, "catalog.json");
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/demo-channel",
channelId: "demo-channel",
label: "Demo Channel",
blurb: "Demo entry",
order: 999,
}),
);
return {
channelId: "demo-channel",
catalogPaths: [catalogPath],
expected: { id: "demo-channel" },
};
},
},
{
name: "preserves plugin ids when they differ from channel ids",
setup: () => {
const stateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-channel-catalog-state-"),
);
writeDiscoveredChannelPlugin({
stateDir,
packageName: "@vendor/demo-channel-plugin",
channelLabel: "Demo Channel",
pluginId: "@vendor/demo-runtime",
blurb: "Demo channel",
});
return {
channelId: "demo-channel",
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
expected: { pluginId: "@vendor/demo-runtime" },
};
},
},
{
name: "keeps discovered plugins ahead of external catalog overrides",
setup: () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
const catalogPath = path.join(stateDir, "catalog.json");
writeDiscoveredChannelPlugin({
stateDir,
packageName: "@vendor/demo-channel-plugin",
channelLabel: "Demo Channel Runtime",
pluginId: "@vendor/demo-channel-runtime",
blurb: "discovered plugin",
});
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@vendor/demo-channel-catalog",
channelId: "demo-channel",
label: "Demo Channel Catalog",
blurb: "external catalog",
}),
);
return {
channelId: "demo-channel",
catalogPaths: [catalogPath],
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
expected: {
install: { npmSpec: "@vendor/demo-channel-plugin" },
meta: { label: "Demo Channel Runtime" },
pluginId: "@vendor/demo-channel-runtime",
},
};
},
},
{
name: "accepts rich external manifest entries with pinned npm metadata",
setup: () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-rich-"));
const catalogPath = path.join(dir, "catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
$schema: "./manifest.schema.json",
schemaVersion: 1,
description:
"Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.",
entries: [
{
name: "@wecom/wecom-openclaw-plugin",
description:
"OpenClaw WeCom (企业微信) channel plugin — community maintained, published on npm.",
source: "external",
kind: "channel",
openclaw: {
channel: {
id: "wecom",
label: "WeCom",
selectionLabel: "WeCom (企业微信)",
detailLabel: "WeCom",
docsPath: "/channels/wecom",
docsLabel: "wecom",
blurb: "企业微信 (WeCom) bot & conversation channel.",
aliases: ["qywx", "wework"],
order: 45,
},
install: {
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
expectedIntegrity: "sha512-wecom",
},
},
},
],
}),
);
return {
channelId: "wecom",
catalogPaths: [catalogPath],
expected: {
id: "wecom",
meta: {
label: "WeCom",
selectionLabel: "WeCom (企业微信)",
detailLabel: "WeCom",
docsPath: "/channels/wecom",
docsLabel: "wecom",
blurb: "企业微信 (WeCom) bot & conversation channel.",
},
install: {
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
expectedIntegrity: "sha512-wecom",
},
installSource: {
defaultChoice: "npm",
npm: {
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
packageName: "@wecom/wecom-openclaw-plugin",
selector: "1.2.3",
selectorKind: "exact-version",
exactVersion: true,
expectedIntegrity: "sha512-wecom",
pinState: "exact-with-integrity",
},
warnings: [],
},
},
};
},
},
{
name: "accepts rich external manifest entries for yuanbao with pinned npm metadata",
setup: () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-yuanbao-"));
const catalogPath = path.join(dir, "catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
$schema: "./manifest.schema.json",
schemaVersion: 1,
description:
"Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.",
entries: [
{
name: "openclaw-plugin-yuanbao",
description:
"OpenClaw Yuanbao (元宝) channel plugin — community maintained, published on npm.",
source: "external",
kind: "channel",
openclaw: {
channel: {
id: "openclaw-plugin-yuanbao",
label: "Yuanbao",
selectionLabel: "Yuanbao (Tencent Yuanbao)",
detailLabel: "Yuanbao",
docsPath: "/channels/yuanbao",
docsLabel: "yuanbao",
blurb: "Tencent Yuanbao AI assistant conversation channel.",
aliases: ["yb", "tencent-yuanbao"],
order: 78,
},
install: {
npmSpec: "openclaw-plugin-yuanbao@1.0.0",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
expectedIntegrity: "sha512-yuanbao",
},
},
},
],
}),
);
return {
channelId: "openclaw-plugin-yuanbao",
catalogPaths: [catalogPath],
expected: {
id: "openclaw-plugin-yuanbao",
meta: {
label: "Yuanbao",
selectionLabel: "Yuanbao (Tencent Yuanbao)",
detailLabel: "Yuanbao",
docsPath: "/channels/yuanbao",
docsLabel: "yuanbao",
blurb: "Tencent Yuanbao AI assistant conversation channel.",
},
install: {
npmSpec: "openclaw-plugin-yuanbao@1.0.0",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
expectedIntegrity: "sha512-yuanbao",
},
},
};
},
},
] as const)("$name", ({ setup }) => {
const setupResult = setup();
const { channelId, expected } = setupResult;
expectCatalogEntryMatch({
channelId,
expected,
...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}),
...("env" in setupResult ? { env: setupResult.env } : {}),
});
});
});
}
export function describeChannelPluginCatalogPathResolutionContract() {
describe("channel plugin catalog path resolution contract", () => {
it.each([
{
name: "uses the provided env for external catalog path resolution",
setup: () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
const catalogPath = path.join(home, "catalog.json");
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/env-demo-channel",
channelId: "env-demo-channel",
label: "Env Demo Channel",
blurb: "Env demo entry",
order: 1000,
}),
);
return {
env: {
...process.env,
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
OPENCLAW_HOME: home,
HOME: home,
},
expectedId: "env-demo-channel",
};
},
},
{
name: "uses the provided env for default catalog paths",
setup: () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
writeCatalogFile(
catalogPath,
createCatalogEntry({
packageName: "@openclaw/default-env-demo",
channelId: "default-env-demo",
label: "Default Env Demo",
blurb: "Default env demo entry",
}),
);
return {
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
},
expectedId: "default-env-demo",
};
},
},
] as const)("$name", ({ setup }) => {
const { env, expectedId } = setup();
expectCatalogIdsContain({ env, expectedId });
});
});
}

View File

@@ -1,193 +0,0 @@
import { describe, expect, it } from "vitest";
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveExplicitConfigWriteTarget,
resolveConfigWriteTargetFromPath,
} from "../../../src/channels/plugins/config-writes.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../src/utils/message-channel.js";
const demoOriginChannelId = "demo-origin";
const demoTargetChannelId = "demo-target";
function makeDemoConfigWritesCfg(accountIdKey: string) {
return {
channels: {
[demoOriginChannelId]: {
configWrites: true,
accounts: {
[accountIdKey]: { configWrites: false },
},
},
[demoTargetChannelId]: {
configWrites: true,
accounts: {
[accountIdKey]: { configWrites: false },
},
},
},
};
}
function expectConfigWriteBlocked(params: {
disabledAccountId: string;
reason: "target-disabled" | "origin-disabled";
blockedScope: "target" | "origin";
}) {
expect(
authorizeConfigWrite({
cfg: makeDemoConfigWritesCfg(params.disabledAccountId),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveExplicitConfigWriteTarget({
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
accountId: "work",
}),
}),
).toEqual({
allowed: false,
reason: params.reason,
blockedScope: {
kind: params.blockedScope,
scope: {
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
accountId: params.blockedScope === "target" ? "work" : "default",
},
},
});
}
function expectAuthorizedConfigWriteCase(
input: Parameters<typeof authorizeConfigWrite>[0],
expected: ReturnType<typeof authorizeConfigWrite>,
) {
expect(authorizeConfigWrite(input)).toEqual(expected);
}
function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) {
expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected);
}
function expectExplicitConfigWriteTargetCase(
input: Parameters<typeof resolveExplicitConfigWriteTarget>[0],
expected: ReturnType<typeof resolveExplicitConfigWriteTarget>,
) {
expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected);
}
function expectFormattedDeniedMessage(
result: Exclude<ReturnType<typeof authorizeConfigWrite>, { allowed: true }>,
) {
expect(
formatConfigWriteDeniedMessage({
result,
}),
).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`);
}
export function describeChannelConfigWritePolicyContract() {
describe("authorizeConfigWrite policy contract", () => {
it.each([
{
name: "blocks when a target account disables writes",
disabledAccountId: "work",
reason: "target-disabled",
blockedScope: "target",
},
{
name: "blocks when the origin account disables writes",
disabledAccountId: "default",
reason: "origin-disabled",
blockedScope: "origin",
},
] as const)("$name", (testCase) => {
expectConfigWriteBlocked(testCase);
});
it.each([
{
name: "allows bypass for internal operator.admin writes",
input: {
cfg: makeDemoConfigWritesCfg("work"),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveExplicitConfigWriteTarget({
channelId: demoTargetChannelId,
accountId: "work",
}),
allowBypass: canBypassConfigWritePolicy({
channel: INTERNAL_MESSAGE_CHANNEL,
gatewayClientScopes: ["operator.admin"],
}),
},
expected: { allowed: true },
},
{
name: "treats non-channel config paths as global writes",
input: {
cfg: makeDemoConfigWritesCfg("work"),
origin: { channelId: demoOriginChannelId, accountId: "default" },
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
},
expected: { allowed: true },
},
] as const)("$name", ({ input, expected }) => {
expectAuthorizedConfigWriteCase(input, expected);
});
});
}
export function describeChannelConfigWriteTargetContract() {
describe("authorizeConfigWrite target contract", () => {
it.each([
{
name: "rejects bare channel collection writes",
pathSegments: ["channels", "demo-channel"],
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
},
{
name: "rejects account collection writes",
pathSegments: ["channels", "demo-channel", "accounts"],
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
},
] as const)("$name", ({ pathSegments, expected }) => {
expectResolvedConfigWriteTargetCase(pathSegments, expected);
});
it.each([
{
name: "resolves explicit channel target",
input: { channelId: demoOriginChannelId },
expected: {
kind: "channel",
scope: { channelId: demoOriginChannelId },
},
},
{
name: "resolves explicit account target",
input: { channelId: demoTargetChannelId, accountId: "work" },
expected: {
kind: "account",
scope: { channelId: demoTargetChannelId, accountId: "work" },
},
},
] as const)("$name", ({ input, expected }) => {
expectExplicitConfigWriteTargetCase(input, expected);
});
it.each([
{
name: "formats denied messages consistently",
result: {
allowed: false,
reason: "target-disabled",
blockedScope: {
kind: "target",
scope: { channelId: demoTargetChannelId, accountId: "work" },
},
} as const,
},
] as const)("$name", ({ result }) => {
expectFormattedDeniedMessage(result);
});
});
}

View File

@@ -1,4 +0,0 @@
export {
expectDirectoryIds,
type DirectoryListFn,
} from "../../../src/plugin-sdk/test-helpers/directory-ids.js";

View File

@@ -1,49 +0,0 @@
import { expect, it } from "vitest";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
type ResolvedGroupPolicy = ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
export type RuntimeGroupPolicyResolver = (
params: Parameters<typeof resolveOpenProviderRuntimeGroupPolicy>[0],
) => ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
export function installChannelRuntimeGroupPolicyFallbackSuite(params: {
configuredLabel: string;
defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open";
missingConfigLabel: string;
missingDefaultLabel: string;
resolve: RuntimeGroupPolicyResolver;
}) {
it(params.missingConfigLabel, () => {
const resolved = params.resolve({
providerConfigPresent: false,
});
expect(resolved.groupPolicy).toBe("allowlist");
expect(resolved.providerMissingFallbackApplied).toBe(true);
});
it(params.configuredLabel, () => {
const resolved = params.resolve({
providerConfigPresent: true,
});
expect(resolved.groupPolicy).toBe("open");
expect(resolved.providerMissingFallbackApplied).toBe(false);
});
it(params.missingDefaultLabel, () => {
const resolved = params.resolve({
providerConfigPresent: false,
defaultGroupPolicy: params.defaultGroupPolicyUnderTest,
});
expect(resolved.groupPolicy).toBe("allowlist");
expect(resolved.providerMissingFallbackApplied).toBe(true);
});
}
export function expectResolvedGroupPolicyCase(
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
) {
expect(resolved.groupPolicy).toBe(expected.groupPolicy);
expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied);
}

View File

@@ -1,6 +0,0 @@
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
const resolveWhatsAppRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
const resolveZaloRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
export { resolveWhatsAppRuntimeGroupPolicy, resolveZaloRuntimeGroupPolicy };

View File

@@ -1,19 +0,0 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type CreateIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter }) => ChannelPlugin;
let createIMessageTestPluginCache: CreateIMessageTestPlugin | undefined;
function getCreateIMessageTestPlugin(): CreateIMessageTestPlugin {
if (!createIMessageTestPluginCache) {
({ createIMessageTestPlugin: createIMessageTestPluginCache } = loadBundledPluginTestApiSync<{
createIMessageTestPlugin: CreateIMessageTestPlugin;
}>("imessage"));
}
return createIMessageTestPluginCache;
}
export const createIMessageTestPlugin: CreateIMessageTestPlugin = (...args) =>
getCreateIMessageTestPlugin()(...args);

View File

@@ -1,18 +0,0 @@
export function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
return new Proxy({} as T, {
get(_target, property) {
const surface = loadSurface();
const value = Reflect.get(surface, property, surface);
return typeof value === "function" ? value.bind(surface) : value;
},
has(_target, property) {
return property in loadSurface();
},
ownKeys() {
return Reflect.ownKeys(loadSurface());
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
},
});
}

View File

@@ -1,23 +0,0 @@
export const channelPluginSurfaceKeys = [
"actions",
"setup",
"status",
"outbound",
"messaging",
"threading",
"directory",
"gateway",
] as const;
export type ChannelPluginSurface = (typeof channelPluginSurfaceKeys)[number];
export const sessionBindingContractChannelIds = [
"bluebubbles",
"discord",
"feishu",
"imessage",
"matrix",
"telegram",
] as const;
export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number];

View File

@@ -1,123 +0,0 @@
import { describe, it } from "vitest";
import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js";
import { channelPluginSurfaceKeys } from "./manifest.js";
import { expectChannelPluginContract } from "./registry-contract-suites.js";
import { getPluginContractRegistryShardRefs } from "./registry-plugin.js";
import {
getDirectoryContractRegistryShardRefs,
getSurfaceContractRegistryShardIds,
getThreadingContractRegistryShardRefs,
} from "./surface-contract-registry.js";
import { expectChannelSurfaceContract } from "./surface-contract-suite.js";
import {
expectChannelDirectoryBaseContract,
expectChannelThreadingBaseContract,
expectChannelThreadingReturnValuesNormalized,
} from "./threading-directory-contract-suites.js";
type ContractShardParams = {
shardIndex: number;
shardCount: number;
};
function installEmptyShardSuite(label: string) {
describe(label, () => {
it("has no matching bundled channels", () => {
// Keeps intentionally empty id-based shards visible to Vitest.
});
});
}
export function installSurfaceContractRegistryShard(params: ContractShardParams) {
const ids = getSurfaceContractRegistryShardIds(params);
if (ids.length === 0) {
installEmptyShardSuite("surface contract registry shard");
return;
}
for (const id of ids) {
describe(`${id} surface contracts`, () => {
it("exposes declared surface contracts", async () => {
const plugin = await getBundledChannelPluginAsync(id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${id}`);
}
const surfaces = channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface]));
for (const surface of surfaces) {
expectChannelSurfaceContract({
plugin,
surface,
});
}
});
});
}
}
export function installDirectoryContractRegistryShard(params: ContractShardParams) {
const entries = getDirectoryContractRegistryShardRefs(params);
if (entries.length === 0) {
installEmptyShardSuite("directory contract registry shard");
return;
}
for (const entry of entries) {
describe(`${entry.id} directory contract`, () => {
it("exposes the base directory contract", async () => {
const plugin = await getBundledChannelPluginAsync(entry.id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
}
await expectChannelDirectoryBaseContract({
plugin,
coverage: entry.coverage,
});
});
});
}
}
export function installThreadingContractRegistryShard(params: ContractShardParams) {
const entries = getThreadingContractRegistryShardRefs(params);
if (entries.length === 0) {
installEmptyShardSuite("threading contract registry shard");
return;
}
for (const entry of entries) {
describe(`${entry.id} threading contract`, () => {
it("exposes the base threading contract", async () => {
const plugin = await getBundledChannelPluginAsync(entry.id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
}
expectChannelThreadingBaseContract(plugin);
});
it("keeps threading return values normalized", async () => {
const plugin = await getBundledChannelPluginAsync(entry.id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
}
expectChannelThreadingReturnValuesNormalized(plugin);
});
});
}
}
export function installPluginContractRegistryShard(params: ContractShardParams) {
const entries = getPluginContractRegistryShardRefs(params);
if (entries.length === 0) {
installEmptyShardSuite("plugin contract registry shard");
return;
}
for (const entry of entries) {
describe(`${entry.id} plugin contract`, () => {
it("satisfies the base channel plugin contract", async () => {
const plugin = await getBundledChannelPluginAsync(entry.id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
}
expectChannelPluginContract(plugin);
});
});
}
}

View File

@@ -1,7 +0,0 @@
export {
expectChannelPluginContract,
installChannelActionsContractSuite,
installChannelPluginContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "../../../src/plugin-sdk/test-helpers/channel-contract-suites.js";

View File

@@ -1,59 +0,0 @@
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import {
getBundledChannelCatalogEntry,
getBundledChannelPlugin,
listBundledChannelPluginIds,
listBundledChannelPlugins,
} from "./bundled-channel-plugin-loader.js";
type PluginContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
};
type PluginContractRef = {
id: ChannelId;
};
function toPluginContractEntry(plugin: ChannelPlugin): PluginContractEntry {
const existingMeta = getBundledChannelCatalogEntry(plugin.id)?.channel;
return {
id: plugin.id,
plugin: {
...plugin,
meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta, existing: existingMeta }),
},
};
}
function getBundledChannelPluginIdsForShard(params: {
shardIndex: number;
shardCount: number;
}): readonly ChannelId[] {
return listBundledChannelPluginIds().filter(
(_id, index) => index % params.shardCount === params.shardIndex,
);
}
export function getPluginContractRegistry(): PluginContractEntry[] {
return listBundledChannelPlugins().map(toPluginContractEntry);
}
export function getPluginContractRegistryShard(params: {
shardIndex: number;
shardCount: number;
}): PluginContractEntry[] {
return getBundledChannelPluginIdsForShard(params).flatMap((id) => {
const plugin = getBundledChannelPlugin(id);
return plugin ? [toPluginContractEntry(plugin)] : [];
});
}
export function getPluginContractRegistryShardRefs(params: {
shardIndex: number;
shardCount: number;
}): PluginContractRef[] {
return getBundledChannelPluginIdsForShard(params).map((id) => ({ id }));
}

View File

@@ -1,630 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
getSessionBindingService,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../src/infra/outbound/session-binding-service.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
import {
sessionBindingContractChannelIds,
type SessionBindingContractChannelId,
} from "./manifest.js";
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
import "../../../src/channels/plugins/registry.js";
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
beforeEach?: () => Promise<void> | void;
};
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
const matrixSessionBindingStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
);
const matrixSessionBindingAuth = {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
async function getContractApi<T extends Record<string, unknown>>(pluginId: string): Promise<T> {
const existing = contractApiPromises.get(pluginId);
if (existing) {
return (await existing) as T;
}
const next = importBundledChannelContractArtifact<T>(pluginId, "contract-api");
contractApiPromises.set(pluginId, next);
return await next;
}
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
}),
)?.toMatchObject({
targetSessionKey: params.targetSessionKey,
});
}
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
const service = getSessionBindingService();
const removed = await service.unbind({
bindingId: binding.bindingId,
reason: "contract-test",
});
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
expect(service.resolveByConversation(binding.conversation)).toBeNull();
}
function expectClearedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
}) {
expect(
getSessionBindingService().resolveByConversation({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
}),
).toBeNull();
}
function resetMatrixSessionBindingStateDir() {
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
}
async function createContractMatrixThreadBindingManager() {
resetMatrixSessionBindingStateDir();
const { setMatrixRuntime, createMatrixThreadBindingManager } =
await getContractApi<MatrixContractApi>("matrix");
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
},
} as never);
return await createMatrixThreadBindingManager({
accountId: matrixSessionBindingAuth.accountId,
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type ChannelConversationBindingManagerFactory = NonNullable<
NonNullable<ChannelPlugin["conversationBindings"]>["createManager"]
>;
type BlueBubblesContractApi = {
blueBubblesConversationBindingTesting: {
resetBlueBubblesConversationBindingsForTests: () => void;
};
createBlueBubblesConversationBindingManager: ChannelConversationBindingManagerFactory;
};
type DiscordContractApi = {
createThreadBindingManager: (params: {
accountId: string;
cfg?: OpenClawConfig;
persist: boolean;
enableSweeper: boolean;
}) => unknown;
discordThreadBindingTesting: {
resetThreadBindingsForTests: () => void;
};
};
type FeishuContractApi = {
createFeishuThreadBindingManager: (params: {
accountId?: string;
cfg: OpenClawConfig;
}) => unknown;
feishuThreadBindingTesting: {
resetFeishuThreadBindingsForTests: () => void;
};
};
type IMessageContractApi = {
createIMessageConversationBindingManager: ChannelConversationBindingManagerFactory;
imessageConversationBindingTesting: {
resetIMessageConversationBindingsForTests: () => void;
};
};
type MatrixContractApi = {
createMatrixThreadBindingManager: (params: {
accountId: string;
auth: typeof matrixSessionBindingAuth;
client: unknown;
idleTimeoutMs: number;
maxAgeMs: number;
enableSweeper: boolean;
}) => Promise<unknown>;
resetMatrixThreadBindingsForTests: () => void;
setMatrixRuntime: (runtime: unknown) => void;
};
type TelegramContractApi = {
createTelegramThreadBindingManager: (params: {
accountId: string;
persist: boolean;
enableSweeper: boolean;
}) => unknown;
resetTelegramThreadBindingsForTests: () => Promise<void>;
};
function setRegistryBackedConversationBindingPlugin(params: {
id: SessionBindingContractChannelId;
createManager: ChannelConversationBindingManagerFactory;
}) {
const plugin = {
id: params.id,
meta: {
id: params.id,
label: params.id,
selectionLabel: params.id,
blurb: "session binding contract fixture",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: params.createManager,
},
} as unknown as ChannelPlugin;
setActivePluginRegistry(
createTestRegistry([
{
pluginId: params.id,
plugin,
source: "test",
},
]),
);
}
async function prepareBlueBubblesSessionBindingContract() {
const api = await getContractApi<BlueBubblesContractApi>("bluebubbles");
api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "bluebubbles",
createManager: api.createBlueBubblesConversationBindingManager,
});
}
async function prepareDiscordSessionBindingContract() {
const api = await getContractApi<DiscordContractApi>("discord");
api.discordThreadBindingTesting.resetThreadBindingsForTests();
}
async function prepareFeishuSessionBindingContract() {
const api = await getContractApi<FeishuContractApi>("feishu");
api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
}
async function prepareIMessageSessionBindingContract() {
const api = await getContractApi<IMessageContractApi>("imessage");
api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "imessage",
createManager: api.createIMessageConversationBindingManager,
});
}
async function prepareMatrixSessionBindingContract() {
const api = await getContractApi<MatrixContractApi>("matrix");
api.resetMatrixThreadBindingsForTests();
}
async function prepareTelegramSessionBindingContract() {
const api = await getContractApi<TelegramContractApi>("telegram");
await api.resetTelegramThreadBindingsForTests();
}
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
bluebubbles: {
beforeEach: prepareBlueBubblesSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "bluebubbles",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
targetKind: "session",
conversation: {
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expectResolvedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "bluebubbles",
accountId: "default",
conversationId: "+15555550123",
});
},
},
discord: {
beforeEach: prepareDiscordSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
},
placement: "current",
metadata: {
agentId: "discord",
label: "discord-child",
},
});
expectResolvedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
});
},
},
feishu: {
beforeEach: prepareFeishuSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:feishu:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "feishu",
label: "feishu-child",
},
});
expectResolvedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetSessionKey: "agent:feishu:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
});
},
},
imessage: {
beforeEach: prepareIMessageSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "imessage",
accountId: "default",
});
},
bindAndResolve: async () => {
await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:imessage:current",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
},
placement: "current",
metadata: {
agentId: "imessage",
label: "imessage-main",
},
});
expectResolvedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
targetSessionKey: "agent:imessage:current",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
});
},
},
matrix: {
beforeEach: prepareMatrixSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:thread",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
},
placement: "current",
metadata: {
agentId: "matrix",
label: "matrix-thread",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
targetSessionKey: "agent:matrix:thread",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
telegram: {
beforeEach: prepareTelegramSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:telegram:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
agentId: "telegram",
label: "telegram-topic",
},
});
expectResolvedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:telegram:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
},
},
};
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
export function getSessionBindingContractRegistry(): SessionBindingContractEntry[] {
sessionBindingContractRegistryCache ??= sessionBindingContractChannelIds.map((id) =>
Object.assign({ id }, sessionBindingContractEntries[id]),
);
return sessionBindingContractRegistryCache;
}

View File

@@ -1,58 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBundledChannelWorkspacePath } from "../../../src/plugins/bundled-channel-runtime.js";
import {
resolvePluginRuntimeModulePath,
resolvePluginRuntimeRecord,
} from "../../../src/plugins/runtime/runtime-plugin-boundary.js";
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
function resolveBundledChannelWorkspaceArtifactPath(
pluginId: string,
entryBaseName: string,
): string | null {
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
const pluginRoot = resolveBundledChannelWorkspacePath({
rootDir: REPO_ROOT,
pluginId,
});
if (!pluginRoot) {
return null;
}
for (const extension of ["js", "ts"]) {
const candidate = path.join(pluginRoot, `${normalizedEntryBaseName}.${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
export function resolveBundledChannelContractArtifactUrl(
pluginId: string,
entryBaseName: string,
): string {
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
const record = resolvePluginRuntimeRecord(pluginId, () => {
throw new Error(`missing bundled channel plugin '${pluginId}'`);
});
if (!record) {
throw new Error(`missing bundled channel plugin '${pluginId}'`);
}
const modulePath =
resolvePluginRuntimeModulePath(record, normalizedEntryBaseName) ??
resolveBundledChannelWorkspaceArtifactPath(pluginId, entryBaseName);
if (!modulePath) {
throw new Error(`missing ${entryBaseName} for bundled channel plugin '${pluginId}'`);
}
return pathToFileURL(modulePath).href;
}
export async function importBundledChannelContractArtifact<T extends object>(
pluginId: string,
entryBaseName: string,
): Promise<T> {
return (await import(resolveBundledChannelContractArtifactUrl(pluginId, entryBaseName))) as T;
}

View File

@@ -1,88 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../src/infra/outbound/session-binding-service.js";
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
import { getSessionBindingContractRegistry } from "./registry-session-binding.js";
function resolveSessionBindingContractRuntimeConfig(id: string) {
if (id !== "discord" && id !== "matrix") {
return {};
}
return {
plugins: {
entries: {
[id]: {
enabled: true,
},
},
},
};
}
function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers, binds, unbinds, and cleans up session bindings", async () => {
expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities);
const binding = await params.bindAndResolve();
try {
expect(typeof binding.bindingId).toBe("string");
expect(binding.bindingId.trim()).not.toBe("");
expect(typeof binding.targetSessionKey).toBe("string");
expect(binding.targetSessionKey.trim()).not.toBe("");
expect(["session", "subagent"]).toContain(binding.targetKind);
expect(typeof binding.conversation.channel).toBe("string");
expect(typeof binding.conversation.accountId).toBe("string");
expect(typeof binding.conversation.conversationId).toBe("string");
expect(["active", "ending", "ended"]).toContain(binding.status);
expect(typeof binding.boundAt).toBe("number");
await params.unbindAndVerify(binding);
} finally {
await params.cleanup();
}
});
}
export function describeSessionBindingRegistryBackedContract(id: string) {
const entry = getSessionBindingContractRegistry().find((item) => item.id === id);
if (!entry) {
throw new Error(`missing session binding contract entry for ${id}`);
}
describe(`${entry.id} session binding contract`, () => {
beforeEach(async () => {
resetPluginRuntimeStateForTest();
clearRuntimeConfigSnapshot();
// Keep the suite hermetic; some contract helpers resolve runtime artifacts through config-aware
// plugin boundaries, so never fall back to the developer's real ~/.openclaw/openclaw.json here.
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
// These registry-backed contract suites intentionally exercise bundled runtime facades.
// Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage.
setRuntimeConfigSnapshot(runtimeConfig);
sessionBindingTesting.resetSessionBindingAdaptersForTests();
await entry.beforeEach?.();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
installSessionBindingContractSuite({
expectedCapabilities: entry.expectedCapabilities,
getCapabilities: entry.getCapabilities,
bindAndResolve: entry.bindAndResolve,
unbindAndVerify: entry.unbindAndVerify,
cleanup: entry.cleanup,
});
});
}

View File

@@ -1,229 +0,0 @@
import type { ChannelId } from "../../../src/channels/plugins/channel-id.types.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
getBundledChannelPlugin,
listBundledChannelPluginIds,
listBundledChannelPlugins,
} from "./bundled-channel-plugin-loader.js";
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
type SurfaceContractEntry = {
id: string;
plugin: Pick<
ChannelPlugin,
| "id"
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway"
>;
surfaces: readonly ChannelPluginSurface[];
};
type ThreadingContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "threading">;
};
type ThreadingContractRef = {
id: ChannelId;
};
type DirectoryContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
};
type DirectoryContractRef = {
id: ChannelId;
coverage: "lookups" | "presence";
};
let surfaceContractRegistryCache: SurfaceContractEntry[] | undefined;
const surfaceContractEntryCache = new Map<ChannelId, SurfaceContractEntry | null>();
let threadingContractRegistryCache: ThreadingContractEntry[] | undefined;
let directoryContractRegistryCache: DirectoryContractEntry[] | undefined;
const threadingContractPluginIds = new Set<ChannelId>([
"bluebubbles",
"discord",
"googlechat",
"matrix",
"mattermost",
"msteams",
"slack",
"telegram",
"zalo",
"zalouser",
]);
const directoryContractPluginIds = new Set<ChannelId>([
"discord",
"feishu",
"googlechat",
"irc",
"line",
"matrix",
"mattermost",
"msteams",
"slack",
"synology-chat",
"telegram",
"whatsapp",
"zalo",
"zalouser",
]);
function toSurfaceContractEntry(plugin: ChannelPlugin): SurfaceContractEntry {
return {
id: plugin.id,
plugin,
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
};
}
function getBundledChannelPluginIdsForShard(params: {
shardIndex: number;
shardCount: number;
}): readonly ChannelId[] {
return listBundledChannelPluginIds().filter(
(_id, index) => index % params.shardCount === params.shardIndex,
);
}
function getSurfaceContractEntry(id: ChannelId): SurfaceContractEntry | undefined {
if (surfaceContractEntryCache.has(id)) {
return surfaceContractEntryCache.get(id) ?? undefined;
}
const plugin = getBundledChannelPlugin(id);
const entry = plugin ? toSurfaceContractEntry(plugin) : null;
surfaceContractEntryCache.set(id, entry);
return entry ?? undefined;
}
export function getSurfaceContractRegistry(): SurfaceContractEntry[] {
surfaceContractRegistryCache ??= listBundledChannelPlugins().map(toSurfaceContractEntry);
return surfaceContractRegistryCache;
}
export function getSurfaceContractRegistryShard(params: {
shardIndex: number;
shardCount: number;
}): SurfaceContractEntry[] {
return getBundledChannelPluginIdsForShard(params).flatMap((id) => {
const entry = getSurfaceContractEntry(id);
return entry ? [entry] : [];
});
}
export function getSurfaceContractRegistryShardIds(params: {
shardIndex: number;
shardCount: number;
}): readonly ChannelId[] {
return getBundledChannelPluginIdsForShard(params);
}
export function getThreadingContractRegistry(): ThreadingContractEntry[] {
threadingContractRegistryCache ??= listBundledChannelPluginIds()
.filter((id) => threadingContractPluginIds.has(id))
.flatMap((id) => {
const entry = getSurfaceContractEntry(id);
return entry && entry.surfaces.includes("threading")
? [
{
id: entry.id,
plugin: entry.plugin,
},
]
: [];
});
return threadingContractRegistryCache;
}
export function getThreadingContractRegistryShard(params: {
shardIndex: number;
shardCount: number;
}): ThreadingContractEntry[] {
return getBundledChannelPluginIdsForShard(params)
.filter((id) => threadingContractPluginIds.has(id))
.flatMap((id) => {
const entry = getSurfaceContractEntry(id);
return entry && entry.surfaces.includes("threading")
? [
{
id: entry.id,
plugin: entry.plugin,
},
]
: [];
});
}
export function getThreadingContractRegistryShardRefs(params: {
shardIndex: number;
shardCount: number;
}): ThreadingContractRef[] {
return getBundledChannelPluginIdsForShard(params)
.filter((id) => threadingContractPluginIds.has(id))
.map((id) => ({ id }));
}
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
export function getDirectoryContractRegistry(): DirectoryContractEntry[] {
directoryContractRegistryCache ??= listBundledChannelPluginIds()
.filter((id) => directoryContractPluginIds.has(id))
.flatMap((id) => {
const entry = getSurfaceContractEntry(id);
return entry && entry.surfaces.includes("directory")
? [
{
id: entry.id,
plugin: entry.plugin,
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
},
]
: [];
});
return directoryContractRegistryCache;
}
export function getDirectoryContractRegistryShard(params: {
shardIndex: number;
shardCount: number;
}): DirectoryContractEntry[] {
return getBundledChannelPluginIdsForShard(params)
.filter((id) => directoryContractPluginIds.has(id))
.flatMap((id) => {
const entry = getSurfaceContractEntry(id);
return entry && entry.surfaces.includes("directory")
? [
{
id: entry.id,
plugin: entry.plugin,
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
},
]
: [];
});
}
export function getDirectoryContractRegistryShardRefs(params: {
shardIndex: number;
shardCount: number;
}): DirectoryContractRef[] {
return getBundledChannelPluginIdsForShard(params)
.filter((id) => directoryContractPluginIds.has(id))
.map((id) => ({
id,
coverage: directoryPresenceOnlyIds.has(id) ? "presence" : "lookups",
}));
}

View File

@@ -1,164 +0,0 @@
import { expect, it } from "vitest";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
export function installChannelSurfaceContractSuite(params: {
plugin: Pick<
ChannelPlugin,
| "id"
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway"
>;
surface:
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway";
}) {
it(`exposes the ${params.surface} surface contract`, () => {
expectChannelSurfaceContract(params);
});
}
export function expectChannelSurfaceContract(params: {
plugin: Pick<
ChannelPlugin,
| "id"
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway"
>;
surface:
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway";
}) {
const { plugin, surface } = params;
if (surface === "actions") {
expect(plugin.actions).toBeDefined();
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
return;
}
if (surface === "setup") {
expect(plugin.setup).toBeDefined();
expect(typeof plugin.setup?.applyAccountConfig).toBe("function");
return;
}
if (surface === "status") {
expect(plugin.status).toBeDefined();
expect(typeof plugin.status?.buildAccountSnapshot).toBe("function");
return;
}
if (surface === "outbound") {
const outbound = plugin.outbound;
expect(outbound).toBeDefined();
expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode);
expect(
[
outbound?.sendPayload,
outbound?.sendFormattedText,
outbound?.sendFormattedMedia,
outbound?.sendText,
outbound?.sendMedia,
outbound?.sendPoll,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
if (surface === "messaging") {
const messaging = plugin.messaging;
expect(messaging).toBeDefined();
expect(
[
messaging?.normalizeTarget,
messaging?.parseExplicitTarget,
messaging?.inferTargetChatType,
messaging?.buildCrossContextPresentation,
messaging?.enableInteractiveReplies,
messaging?.hasStructuredReplyPayload,
messaging?.formatTargetDisplay,
messaging?.resolveOutboundSessionRoute,
].some((value) => typeof value === "function"),
).toBe(true);
if (messaging?.targetResolver) {
if (messaging.targetResolver.looksLikeId) {
expect(typeof messaging.targetResolver.looksLikeId).toBe("function");
}
if (messaging.targetResolver.hint !== undefined) {
expect(typeof messaging.targetResolver.hint).toBe("string");
expect(messaging.targetResolver.hint.trim()).not.toBe("");
}
if (messaging.targetResolver.resolveTarget) {
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
}
}
return;
}
if (surface === "threading") {
const threading = plugin.threading;
expect(threading).toBeDefined();
expect(
[
threading?.resolveReplyToMode,
threading?.buildToolContext,
threading?.resolveAutoThreadId,
threading?.resolveReplyTransport,
threading?.resolveFocusedBinding,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
if (surface === "directory") {
const directory = plugin.directory;
expect(directory).toBeDefined();
expect(
[
directory?.self,
directory?.listPeers,
directory?.listPeersLive,
directory?.listGroups,
directory?.listGroupsLive,
directory?.listGroupMembers,
].some((value) => typeof value === "function"),
).toBe(true);
return;
}
const gateway = plugin.gateway;
expect(gateway).toBeDefined();
expect(
[
gateway?.startAccount,
gateway?.stopAccount,
gateway?.loginWithQrStart,
gateway?.loginWithQrWait,
gateway?.logoutAccount,
].some((value) => typeof value === "function"),
).toBe(true);
}

View File

@@ -1,260 +0,0 @@
import { expect, it } from "vitest";
import type {
ChannelDirectoryEntry,
ChannelFocusedBindingContext,
ChannelReplyTransport,
ChannelThreadingToolContext,
} from "../../../src/channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
let contractRuntime: RuntimeEnv | undefined;
async function getDirectoryContractRuntime(): Promise<RuntimeEnv> {
if (contractRuntime) {
return contractRuntime;
}
const { createNonExitingRuntime } = await import("../../../src/runtime.js");
contractRuntime = createNonExitingRuntime();
return contractRuntime;
}
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
expect(["user", "group", "channel"]).toContain(entry.kind);
expect(typeof entry.id).toBe("string");
expect(entry.id.trim()).not.toBe("");
if (entry.name !== undefined) {
expect(typeof entry.name).toBe("string");
}
if (entry.handle !== undefined) {
expect(typeof entry.handle).toBe("string");
}
if (entry.avatarUrl !== undefined) {
expect(typeof entry.avatarUrl).toBe("string");
}
if (entry.rank !== undefined) {
expect(typeof entry.rank).toBe("number");
}
}
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
if (context.currentChannelId !== undefined) {
expect(typeof context.currentChannelId).toBe("string");
}
if (context.currentChannelProvider !== undefined) {
expect(typeof context.currentChannelProvider).toBe("string");
}
if (context.currentThreadTs !== undefined) {
expect(typeof context.currentThreadTs).toBe("string");
}
if (context.currentMessageId !== undefined) {
expect(["string", "number"]).toContain(typeof context.currentMessageId);
}
if (context.replyToMode !== undefined) {
expect(["off", "first", "all"]).toContain(context.replyToMode);
}
if (context.hasRepliedRef !== undefined) {
expect(typeof context.hasRepliedRef).toBe("object");
}
if (context.skipCrossContextDecoration !== undefined) {
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
}
}
function expectReplyTransportShape(transport: ChannelReplyTransport) {
if (transport.replyToId !== undefined && transport.replyToId !== null) {
expect(typeof transport.replyToId).toBe("string");
}
if (transport.threadId !== undefined && transport.threadId !== null) {
expect(["string", "number"]).toContain(typeof transport.threadId);
}
}
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
expect(typeof binding.conversationId).toBe("string");
expect(binding.conversationId.trim()).not.toBe("");
if (binding.parentConversationId !== undefined) {
expect(typeof binding.parentConversationId).toBe("string");
}
expect(["current", "child"]).toContain(binding.placement);
expect(typeof binding.labelNoun).toBe("string");
expect(binding.labelNoun.trim()).not.toBe("");
}
export function installChannelThreadingContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "threading">;
}) {
it("exposes the base threading contract", () => {
expectChannelThreadingBaseContract(params.plugin);
});
it("keeps threading return values normalized", () => {
expectChannelThreadingReturnValuesNormalized(params.plugin);
});
}
export function expectChannelThreadingBaseContract(
plugin: Pick<ChannelPlugin, "id" | "threading">,
) {
expect(plugin.threading).toBeDefined();
}
export function expectChannelThreadingReturnValuesNormalized(
plugin: Pick<ChannelPlugin, "id" | "threading">,
) {
const threading = plugin.threading;
expect(threading).toBeDefined();
if (threading?.resolveReplyToMode) {
expect(
["off", "first", "all"].includes(
threading.resolveReplyToMode({
cfg: {} as OpenClawConfig,
accountId: "default",
chatType: "group",
}),
),
).toBe(true);
}
const repliedRef = { value: false };
const toolContext = threading?.buildToolContext?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
hasRepliedRef: repliedRef,
});
if (toolContext) {
expectThreadingToolContextShape(toolContext);
if (toolContext.hasRepliedRef) {
expect(toolContext.hasRepliedRef).toBe(repliedRef);
}
}
const autoThreadId = threading?.resolveAutoThreadId?.({
cfg: {} as OpenClawConfig,
accountId: "default",
to: "group:test",
toolContext,
replyToId: null,
});
if (autoThreadId !== undefined) {
expect(typeof autoThreadId).toBe("string");
expect(autoThreadId.trim()).not.toBe("");
}
const replyTransport = threading?.resolveReplyTransport?.({
cfg: {} as OpenClawConfig,
accountId: "default",
threadId: "thread-0",
replyToId: "msg-0",
});
if (replyTransport) {
expectReplyTransportShape(replyTransport);
}
const focusedBinding = threading?.resolveFocusedBinding?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
});
if (focusedBinding) {
expectFocusedBindingShape(focusedBinding);
}
}
export function installChannelDirectoryContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage?: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
}) {
it("exposes the base directory contract", async () => {
await expectChannelDirectoryBaseContract(params);
});
}
export async function expectChannelDirectoryBaseContract(params: {
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage?: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
}) {
const directory = params.plugin.directory;
expect(directory).toBeDefined();
if (params.coverage === "presence") {
return;
}
const runtime = await getDirectoryContractRuntime();
const self = await directory?.self?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
runtime,
});
if (self) {
expectDirectoryEntryShape(self);
}
const peers =
(await directory?.listPeers?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
query: "",
limit: 5,
runtime,
})) ?? [];
expect(Array.isArray(peers)).toBe(true);
for (const peer of peers) {
expectDirectoryEntryShape(peer);
}
const groups =
(await directory?.listGroups?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
query: "",
limit: 5,
runtime,
})) ?? [];
expect(Array.isArray(groups)).toBe(true);
for (const group of groups) {
expectDirectoryEntryShape(group);
}
if (directory?.listGroupMembers && groups[0]?.id) {
const members = await directory.listGroupMembers({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
groupId: groups[0].id,
limit: 5,
runtime,
});
expect(Array.isArray(members)).toBe(true);
for (const member of members) {
expectDirectoryEntryShape(member);
}
}
}