重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,31 @@
# Shared Test Helper Boundary
This directory holds shared test helpers reused by core and bundled plugin
tests.
## Bundled Plugin Imports
- Shared helpers in this tree must not hardcode repo-relative imports into
`extensions/**`.
- When a helper needs a bundled plugin public surface, go through
`src/test-utils/bundled-plugin-public-surface.ts`.
- Prefer `loadBundledPluginApiSync(...)`,
`loadBundledPluginRuntimeApiSync(...)`,
`loadBundledPluginContractApiSync(...)`, and
`loadBundledPluginTestApiSync(...)` for eager access to exported surfaces.
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` or
`resolveBundledPluginPublicModulePath(...)` when a helper needs a module id
or filesystem path for dynamic import, mocking, or loading a plugin entrypoint
such as `index.js`.
- If `vi.hoisted(...)` is involved, do not call imported helper functions from
inside the hoisted callback. Resolve the module id outside the callback or
switch to `vi.doMock(...)`.
- Do not keep plugin-local deep mocks or private `src/**` knowledge in shared
helpers. Move those helpers into the owning bundled plugin package instead.
## Intent
- Keep shared helpers aligned with the same public/plugin boundary that
production code uses.
- Avoid shared helper debt that makes core test lanes depend on bundled plugin
private layout.

View file

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

View file

@ -0,0 +1,19 @@
import { vi } from "vitest";
type PiAiMockModule = Record<string, unknown>;
export async function createPiAiStreamSimpleMock(
loadActual: () => Promise<PiAiMockModule>,
): Promise<PiAiMockModule> {
const original = await loadActual();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(async () => undefined),
[Symbol.asyncIterator]: vi.fn(async function* () {
// Minimal async stream shape for wrappers that patch iteration/result.
}),
})),
};
}

View file

@ -0,0 +1,689 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
appendBootstrapPromptWarning,
analyzeBootstrapBudget,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
} from "../../../src/agents/bootstrap-budget.js";
import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js";
import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js";
import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js";
import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js";
import { buildGroupChatContext, buildGroupIntro } from "../../../src/auto-reply/reply/groups.js";
import {
buildInboundMetaSystemPrompt,
buildInboundUserContextPrefix,
} from "../../../src/auto-reply/reply/inbound-meta.js";
import type { TemplateContext } from "../../../src/auto-reply/templating.js";
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js";
export type PromptScenarioTurn = {
id: string;
label: string;
systemPrompt: string;
bodyPrompt: string;
notes: string[];
};
export type PromptScenario = {
scenario: string;
focus: string;
expectedStableSystemAfterTurnIds: string[];
turns: PromptScenarioTurn[];
};
function buildCommonSystemParams(workspaceDir: string) {
const toolNames = [
"bash",
"read",
"edit",
"grep",
"glob",
"message",
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
];
return {
runtimeInfo: {
agentId: "main",
host: "cache-lab",
repoRoot: workspaceDir,
os: "Darwin 24.0.0",
arch: "arm64",
node: process.version,
model: "anthropic/claude-sonnet-4-5",
defaultModel: "anthropic/claude-sonnet-4-5",
shell: "zsh",
},
userTimezone: "America/Los_Angeles",
userTime: "Monday, March 16th, 2026 - 9:00 PM",
userTimeFormat: "12" as const,
toolNames,
};
}
function buildSystemPrompt(params: {
workspaceDir: string;
extraSystemPrompt?: string;
skillsPrompt?: string;
reactionGuidance?: { level: "minimal" | "extensive"; channel: string };
contextFiles?: Array<{ path: string; content: string }>;
}) {
const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames } =
buildCommonSystemParams(params.workspaceDir);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
extraSystemPrompt: params.extraSystemPrompt,
runtimeInfo,
userTimezone,
userTime,
userTimeFormat,
toolNames,
modelAliasLines: [],
promptMode: "full",
acpEnabled: true,
skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
contextFiles: params.contextFiles,
});
}
function buildAutoReplyBody(params: { ctx: TemplateContext; body: string; eventLine?: string }) {
return [params.eventLine, buildInboundUserContextPrefix(params.ctx), params.body]
.filter(Boolean)
.join("\n\n");
}
async function readContextFiles(workspaceDir: string, fileNames: string[]) {
return Promise.all(
fileNames.map(async (fileName) => ({
path: fileName,
content: await fs.readFile(path.join(workspaceDir, fileName), "utf-8"),
})),
);
}
function buildAutoReplySystemPrompt(params: {
workspaceDir: string;
sessionCtx: TemplateContext;
includeGroupChatContext?: boolean;
includeGroupIntro?: boolean;
groupSystemPrompt?: string;
}) {
const extraSystemPromptParts = [
buildInboundMetaSystemPrompt(params.sessionCtx),
params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx }) : "",
params.includeGroupIntro
? buildGroupIntro({
cfg: {} as OpenClawConfig,
sessionCtx: params.sessionCtx,
defaultActivation: "mention",
silentToken: SILENT_REPLY_TOKEN,
})
: "",
params.groupSystemPrompt?.trim() ?? "",
].filter(Boolean);
return buildSystemPrompt({
workspaceDir: params.workspaceDir,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
});
}
function buildToolRichSystemPrompt(params: {
workspaceDir: string;
skillsPrompt: string;
contextFiles: Array<{ path: string; content: string }>;
}) {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildCommonSystemParams(
params.workspaceDir,
);
const tools = [
"bash",
"read",
"edit",
"grep",
"glob",
"message",
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
].map((name) => Object.assign({}, createStubTool(name), { description: `${name} tool` }));
return buildEmbeddedSystemPrompt({
workspaceDir: params.workspaceDir,
reasoningTagHint: false,
runtimeInfo,
tools,
modelAliasLines: [],
userTimezone,
userTime,
userTimeFormat,
acpEnabled: true,
skillsPrompt: params.skillsPrompt,
reactionGuidance: { level: "extensive", channel: "Telegram" },
contextFiles: params.contextFiles,
});
}
function createDirectScenario(workspaceDir: string): PromptScenario {
const baseCtx: TemplateContext = {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
SenderId: "U1",
SenderName: "Alice",
Body: "hi",
BodyStripped: "hi",
};
return {
scenario: "auto-reply-direct",
focus:
"Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection",
expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"],
turns: [
{
id: "t1",
label: "Direct turn with reply context",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m1",
ReplyToId: "r1",
ReplyToBody: "prior message",
WasMentioned: true,
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m1",
ReplyToId: "r1",
ReplyToBody: "prior message",
WasMentioned: true,
},
body: "Please summarize yesterday's decision.",
}),
notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"],
},
{
id: "t2",
label: "Direct turn with new message id",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m2",
ReplyToId: "r2",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m2",
ReplyToId: "r2",
},
body: "Now open the read tool and inspect AGENTS.md.",
}),
notes: ["Steady-state direct turn", "No runtime event"],
},
{
id: "t3",
label: "Direct turn with runtime event and think hint",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m3",
ReplyToId: "r3",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m3",
ReplyToId: "r3",
},
eventLine: "System: [t] Model switched.",
body: "low use tools if needed and tell me which file controls startup behavior",
}),
notes: ["Touches runtime event body path", "Touches think-hint parsing path"],
},
{
id: "t4",
label: "Direct turn after runtime event",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "m4",
ReplyToId: "r4",
},
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "m4",
ReplyToId: "r4",
},
body: "Repeat the startup file path only.",
}),
notes: ["Checks steady-state after event turn"],
},
],
};
}
function createGroupScenario(workspaceDir: string): PromptScenario {
const baseCtx: TemplateContext = {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "C123",
AccountId: "A1",
ChatType: "group",
GroupSubject: "ops",
GroupChannel: "#ops",
GroupMembers: "Bob, Cara, Dan, Eve",
SenderId: "U2",
SenderName: "Bob",
Body: "hi",
BodyStripped: "hi",
};
return {
scenario: "auto-reply-group",
focus: "Group chat bootstrap, steady state, and runtime event turns",
expectedStableSystemAfterTurnIds: ["t3"],
turns: [
{
id: "t1",
label: "First group turn with one-time intro",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g1",
WasMentioned: true,
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
},
includeGroupChatContext: true,
includeGroupIntro: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g1",
WasMentioned: true,
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
},
body: "Can you investigate this issue?",
}),
notes: ["Expected first-turn bootstrap churn", "Not steady-state"],
},
{
id: "t2",
label: "Steady-state group turn",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
includeGroupChatContext: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
body: "Give a short update.",
}),
notes: ["One-time intro gone", "Should settle afterward"],
},
{
id: "t3",
label: "Group turn with runtime event",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
...baseCtx,
MessageSid: "g2",
WasMentioned: false,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
],
},
includeGroupChatContext: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
...baseCtx,
MessageSid: "g3",
WasMentioned: true,
InboundHistory: [
{ sender: "Cara", timestamp: 1, body: "status?" },
{ sender: "Dan", timestamp: 2, body: "please help" },
{ sender: "Eve", timestamp: 3, body: "what changed?" },
],
},
eventLine: "System: [t] Node connected.",
body: "Tell the room whether tools are available.",
}),
notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"],
},
],
};
}
async function createToolRichScenario(workspaceDir: string): Promise<PromptScenario> {
const skillsPrompt = [
"<available_skills>",
"<skill><name>checks</name><description>Run checks before landing changes.</description><location>/skills/checks/SKILL.md</location></skill>",
"<skill><name>release</name><description>Release OpenClaw safely.</description><location>/skills/release/SKILL.md</location></skill>",
"</available_skills>",
].join("\n");
const contextFiles = await readContextFiles(workspaceDir, ["AGENTS.md", "TOOLS.md", "SOUL.md"]);
const systemPrompt = buildToolRichSystemPrompt({
workspaceDir,
skillsPrompt,
contextFiles,
});
return {
scenario: "tool-rich-agent-run",
focus:
"Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls",
expectedStableSystemAfterTurnIds: ["t2"],
turns: [
{
id: "t1",
label: "Tool-rich turn asking for search, read, and file edits",
systemPrompt,
bodyPrompt: [
"Conversation info (untrusted metadata):",
"```json",
JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2),
"```",
"",
"high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.",
].join("\n"),
notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"],
},
{
id: "t2",
label: "Follow-up after a fictional tool call",
systemPrompt,
bodyPrompt: [
"Conversation info (untrusted metadata):",
"```json",
JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2),
"```",
"",
"Tool transcript summary (untrusted, for context):",
"```json",
JSON.stringify(
[
{ role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" },
{ role: "tool", name: "read", result: "Loaded AGENTS.md" },
{ role: "assistant", action: "tool_use", name: "grep", target: "failing test" },
{ role: "tool", name: "grep", result: "Matched src/foo.ts:42" },
],
null,
2,
),
"```",
"",
"Continue and explain the root cause.",
].join("\n"),
notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"],
},
],
};
}
async function createBootstrapWarningScenario(workspaceDir: string): Promise<PromptScenario> {
const bootstrapConfig = {
agents: {
defaults: {
bootstrapMaxChars: 1_500,
bootstrapTotalMaxChars: 2_200,
},
},
} satisfies OpenClawConfig;
const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000);
const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000);
await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents });
await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools });
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
workspaceDir,
config: bootstrapConfig,
});
const analysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles,
injectedFiles: contextFiles,
}),
bootstrapMaxChars: bootstrapConfig.agents.defaults.bootstrapMaxChars,
bootstrapTotalMaxChars: bootstrapConfig.agents.defaults.bootstrapTotalMaxChars,
});
if (!analysis.hasTruncation) {
throw new Error("bootstrap-warning scenario expected truncated bootstrap context");
}
const warningFirst = buildBootstrapPromptWarning({
analysis,
mode: "once",
seenSignatures: [],
});
const warningSeen = buildBootstrapPromptWarning({
analysis,
mode: "once",
seenSignatures: warningFirst.warningSignaturesSeen,
previousSignature: warningFirst.signature,
});
const warningAlways = buildBootstrapPromptWarning({
analysis,
mode: "always",
seenSignatures: warningFirst.warningSignaturesSeen,
previousSignature: warningFirst.signature,
});
return {
scenario: "bootstrap-warning",
focus: "Workspace bootstrap truncation warnings inside # Project Context",
expectedStableSystemAfterTurnIds: ["t2", "t3"],
turns: [
{
id: "t1",
label: "First warning emission",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines),
notes: ["Warning is appended to the turn body", "System prompt should stay stable"],
},
{
id: "t2",
label: "Same truncation signature after once-mode dedupe",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines),
notes: ["Once-mode removes warning lines", "Only the body tail changes now"],
},
{
id: "t3",
label: "Always-mode warning",
systemPrompt: buildSystemPrompt({
workspaceDir,
contextFiles,
}),
bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines),
notes: [
"Always-mode keeps warning in the body prompt tail",
"System prompt remains stable",
],
},
],
};
}
async function createMaintenanceScenario(workspaceDir: string): Promise<PromptScenario> {
await writeWorkspaceFile({
dir: workspaceDir,
name: "AGENTS.md",
content: [
"## Session Startup",
"Read AGENTS.md and MEMORY.md before responding.",
"",
"## Red Lines",
"Do not delete production data.",
"",
"## Safety",
"Never reveal secrets.",
].join("\n"),
});
const memoryFlushPrompt = [
"Pre-compaction memory flush.",
"Store durable memories only in memory/2026-03-15.md (create memory/ if needed).",
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.",
"If nothing to store, reply with NO_REPLY.",
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
].join("\n");
const memoryFlushSystemPrompt = buildSystemPrompt({
workspaceDir,
extraSystemPrompt: [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).",
"You may reply, but usually NO_REPLY is correct.",
].join(" "),
});
const postCompaction = [
"[Post-compaction context refresh]",
"",
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.",
"",
"Critical rules from AGENTS.md:",
"",
"## Session Startup",
"Read AGENTS.md and MEMORY.md before responding.",
"",
"## Red Lines",
"Do not delete production data.",
"",
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
].join("\n");
const postCompactionSystemPrompt = buildSystemPrompt({
workspaceDir,
extraSystemPrompt: buildInboundMetaSystemPrompt({
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
}),
});
return {
scenario: "maintenance-prompts",
focus: "Memory flush and post-compaction maintenance prompts",
expectedStableSystemAfterTurnIds: [],
turns: [
{
id: "t1",
label: "Pre-compaction memory flush run",
systemPrompt: memoryFlushSystemPrompt,
bodyPrompt: memoryFlushPrompt,
notes: [
"Writes to memory/2026-03-15.md",
"Separate maintenance run; expected to differ from normal user turns",
],
},
{
id: "t2",
label: "Post-compaction refresh context run",
systemPrompt: postCompactionSystemPrompt,
bodyPrompt: postCompaction,
notes: [
"Separate maintenance context payload",
"Expected to differ from normal user turns",
],
},
],
};
}
export async function createWorkspaceWithPromptCompositionFiles(): Promise<string> {
const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-");
await writeWorkspaceFile({
dir: workspaceDir,
name: "AGENTS.md",
content: [
"# AGENTS.md",
"",
"## Session Startup",
"Read AGENTS.md and TOOLS.md before making changes.",
"",
"## Red Lines",
"Do not rewrite user commits.",
].join("\n"),
});
await writeWorkspaceFile({
dir: workspaceDir,
name: "TOOLS.md",
content: "# TOOLS.md\n\nUse rg before grep.\n",
});
await writeWorkspaceFile({
dir: workspaceDir,
name: "SOUL.md",
content: "# SOUL.md\n\nBe concise but kind.\n",
});
return workspaceDir;
}
export async function createPromptCompositionScenarios(): Promise<{
workspaceDir: string;
warningWorkspaceDir: string;
scenarios: PromptScenario[];
cleanup: () => Promise<void>;
}> {
const workspaceDir = await createWorkspaceWithPromptCompositionFiles();
const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-");
const scenarios = [
createDirectScenario(workspaceDir),
createGroupScenario(workspaceDir),
await createToolRichScenario(workspaceDir),
await createBootstrapWarningScenario(warningWorkspaceDir),
await createMaintenanceScenario(workspaceDir),
];
return {
workspaceDir,
warningWorkspaceDir,
scenarios,
cleanup: async () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(warningWorkspaceDir, { recursive: true, force: true });
},
};
}

View file

@ -0,0 +1,92 @@
import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest";
import type { RuntimeEnv } from "../../src/runtime.js";
import { makeTempWorkspace } from "../../src/test-helpers/workspace.js";
import { captureEnv } from "../../src/test-utils/env.js";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
export const noopAsync = async () => {};
export const noop = () => {};
export function createExitThrowingRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
export function createWizardPrompter(
overrides: Partial<WizardPrompter>,
options?: { defaultSelect?: string },
): WizardPrompter {
return {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => (options?.defaultSelect ?? "") as never),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
...overrides,
};
}
export async function setupAuthTestEnv(
prefix = "openclaw-auth-",
options?: { agentSubdir?: string },
): Promise<{
stateDir: string;
agentDir: string;
}> {
const stateDir = await makeTempWorkspace(prefix);
const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
await fs.mkdir(agentDir, { recursive: true });
return { stateDir, agentDir };
}
export type AuthTestLifecycle = {
setStateDir: (stateDir: string) => void;
cleanup: () => Promise<void>;
};
export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
const envSnapshot = captureEnv(envKeys);
let stateDir: string | null = null;
return {
setStateDir(nextStateDir: string) {
stateDir = nextStateDir;
},
async cleanup() {
if (stateDir) {
await fs.rm(stateDir, { recursive: true, force: true });
stateDir = null;
}
envSnapshot.restore();
},
};
}
export function requireOpenClawAgentDir(): string {
const agentDir = process.env.OPENCLAW_AGENT_DIR;
if (!agentDir) {
throw new Error("OPENCLAW_AGENT_DIR not set");
}
return agentDir;
}
export function authProfilePathForAgent(agentDir: string): string {
return path.join(agentDir, "auth-profiles.json");
}
export async function readAuthProfilesForAgent<T>(agentDir: string): Promise<T> {
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
return JSON.parse(raw) as T;
}

View file

@ -0,0 +1,483 @@
import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js";
import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js";
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
import { resetProviderRuntimeHookCacheForTest } from "../../../src/plugins/provider-runtime.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyMock = any;
type AnyMocks = Record<string, any>;
function getSharedMocks<T>(key: string, create: () => T): T {
const symbol = Symbol.for(key);
const store = globalThis as Record<symbol, T | undefined>;
if (!store[symbol]) {
store[symbol] = create();
}
return store[symbol];
}
const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mocks", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
export function getAbortEmbeddedPiRunMock(): AnyMock {
return piEmbeddedMocks.abortEmbeddedPiRun;
}
export function getCompactEmbeddedPiSessionMock(): AnyMock {
return piEmbeddedMocks.compactEmbeddedPiSession;
}
export function getRunEmbeddedPiAgentMock(): AnyMock {
return piEmbeddedMocks.runEmbeddedPiAgent;
}
export function getQueueEmbeddedPiMessageMock(): AnyMock {
return piEmbeddedMocks.queueEmbeddedPiMessage;
}
const installPiEmbeddedMock = () =>
vi.doMock("../../../src/agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
compactEmbeddedPiSession: (...args: unknown[]) =>
piEmbeddedMocks.compactEmbeddedPiSession(...args),
runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args),
queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args),
isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args),
isEmbeddedPiRunStreaming: (...args: unknown[]) =>
piEmbeddedMocks.isEmbeddedPiRunStreaming(...args),
}));
installPiEmbeddedMock();
vi.doMock("../../../src/agents/pi-embedded-runner/runs.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
}));
const providerUsageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
export function getProviderUsageMocks(): AnyMocks {
return providerUsageMocks;
}
vi.mock("../../../src/infra/provider-usage.js", () => providerUsageMocks);
const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-6",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.4", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
export function getModelCatalogMocks(): AnyMocks {
return modelCatalogMocks;
}
const installModelCatalogMock = () =>
vi.doMock("../../../src/agents/model-catalog.js", () => modelCatalogMocks);
installModelCatalogMock();
vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
}));
vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
params.catalog ?? [],
buildProviderAuthDoctorHintWithPlugin: () => undefined,
buildProviderMissingAuthMessageWithPlugin: () => undefined,
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
prepareProviderRuntimeAuth: async () => undefined,
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
}));
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
runWithModelFallback: vi.fn(
async (params: {
provider: string;
model: string;
run: (provider: string, model: string, runOptions?: unknown) => Promise<unknown>;
}) => ({
result: await params.run(params.provider, params.model),
provider: params.provider,
model: params.model,
attempts: [],
}),
),
}));
export function getModelFallbackMocks(): AnyMocks {
return modelFallbackMocks;
}
const installModelFallbackMock = () =>
vi.doMock("../../../src/agents/model-fallback.js", () => modelFallbackMocks);
installModelFallbackMock();
vi.doMock("../../../src/infra/git-commit.js", () => ({
resolveCommitHash: vi.fn(() => "abcdef0"),
}));
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
const whatsappRuntimeApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "whatsapp",
artifactBasename: "runtime-api.js",
});
export function getWebSessionMocks(): AnyMocks {
return webSessionMocks;
}
const installWebSessionMock = () => vi.doMock(whatsappRuntimeApiModuleId, () => webSessionMocks);
installWebSessionMock();
export const MAIN_SESSION_KEY = "agent:main:main";
type TempHomeEnvSnapshot = {
home: string | undefined;
userProfile: string | undefined;
homeDrive: string | undefined;
homePath: string | undefined;
openclawHome: string | undefined;
stateDir: string | undefined;
};
let suiteTempHomeRoot = "";
let suiteTempHomeId = 0;
function snapshotTempHomeEnv(): TempHomeEnvSnapshot {
return {
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
homeDrive: process.env.HOMEDRIVE,
homePath: process.env.HOMEPATH,
openclawHome: process.env.OPENCLAW_HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreTempHomeEnv(snapshot: TempHomeEnvSnapshot): void {
const restoreKey = (key: string, value: string | undefined) => {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
};
restoreKey("HOME", snapshot.home);
restoreKey("USERPROFILE", snapshot.userProfile);
restoreKey("HOMEDRIVE", snapshot.homeDrive);
restoreKey("HOMEPATH", snapshot.homePath);
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
}
function setTempHomeEnv(home: string): void {
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = join(home, ".openclaw");
if (process.platform !== "win32") {
return;
}
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (!match) {
return;
}
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
beforeAll(async () => {
suiteTempHomeRoot = await fs.mkdtemp(join(os.tmpdir(), "openclaw-triggers-suite-"));
});
afterAll(async () => {
if (!suiteTempHomeRoot) {
return;
}
try {
rmSync(suiteTempHomeRoot, { recursive: true, force: true });
} catch {
// Best-effort temp cleanup only.
}
suiteTempHomeRoot = "";
suiteTempHomeId = 0;
});
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = join(suiteTempHomeRoot, `case-${++suiteTempHomeId}`);
const snapshot = snapshotTempHomeEnv();
await fs.mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
setTempHomeEnv(home);
try {
// Hard reset shared mocks so non-isolated runs don't inherit prior behavior.
piEmbeddedMocks.runEmbeddedPiAgent.mockReset();
piEmbeddedMocks.abortEmbeddedPiRun.mockReset().mockReturnValue(false);
piEmbeddedMocks.compactEmbeddedPiSession.mockReset();
piEmbeddedMocks.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
piEmbeddedMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
piEmbeddedMocks.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
modelFallbackMocks.runWithModelFallback.mockClear();
return await fn(home);
} finally {
restoreTempHomeEnv(snapshot);
}
}
export function makeCfg(home: string): OpenClawConfig {
return withFastReplyConfig({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: join(home, "openclaw"),
// Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites.
blockStreamingCoalesce: { idleMs: 1 },
// Trigger tests assert routing/authorization behavior, not delivery pacing.
humanDelay: { mode: "off" },
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
messages: {
queue: {
debounceMs: 0,
},
},
session: { store: join(home, "sessions.json") },
} as OpenClawConfig);
}
export async function loadGetReplyFromConfig() {
return (await import("../../../src/auto-reply/reply.js")).getReplyFromConfig;
}
export function installTriggerHandlingReplyHarness(
setGetReplyFromConfig: (
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig,
) => void,
): void {
beforeAll(async () => {
setGetReplyFromConfig(await loadGetReplyFromConfig());
});
installTriggerHandlingE2eTestHooks();
}
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
const storePath = cfg.session?.store;
if (!storePath) {
throw new Error("expected session store path");
}
return storePath;
}
export async function readSessionStore(cfg: {
session?: { store?: string };
}): Promise<Record<string, { elevatedLevel?: string }>> {
const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8");
return JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
}
export function makeWhatsAppElevatedCfg(
home: string,
opts?: { elevatedEnabled?: boolean; requireMentionInGroups?: boolean },
): OpenClawConfig {
const cfg = makeCfg(home);
cfg.channels ??= {};
cfg.channels.whatsapp = {
...cfg.channels.whatsapp,
allowFrom: ["+1000"],
};
if (opts?.requireMentionInGroups !== undefined) {
cfg.channels.whatsapp.groups = { "*": { requireMention: opts.requireMentionInGroups } };
}
cfg.tools = {
...cfg.tools,
elevated: {
allowFrom: { whatsapp: ["+1000"] },
...(opts?.elevatedEnabled === false ? { enabled: false } : {}),
},
};
return cfg;
}
export async function runDirectElevatedToggleAndLoadStore(params: {
cfg: OpenClawConfig;
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
body?: string;
}): Promise<{
text: string | undefined;
store: Record<string, { elevatedLevel?: string }>;
}> {
const res = await params.getReplyFromConfig(
{
Body: params.body ?? "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
CommandAuthorized: true,
},
{},
params.cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const storePath = params.cfg.session?.store;
if (!storePath) {
throw new Error("session.store is required in test config");
}
const store = await readSessionStore(params.cfg);
return { text, store };
}
export async function expectInlineCommandHandledAndStripped(params: {
home: string;
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
body: string;
stripToken: string;
blockReplyContains: string;
requestOverrides?: Record<string, unknown>;
}) {
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
runEmbeddedPiAgentMock.mockClear();
const { blockReplies, handlers } = createBlockReplyCollector();
const res = await params.getReplyFromConfig(
{
Body: params.body,
From: "+1002",
To: "+2000",
CommandAuthorized: true,
...params.requestOverrides,
},
handlers,
makeCfg(params.home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(blockReplies.length).toBe(1);
expect(blockReplies[0]?.text).toContain(params.blockReplyContains);
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).not.toContain(params.stripToken);
expect(text).toBe("ok");
}
export async function runGreetingPromptForBareNewOrReset(params: {
home: string;
body: "/new" | "/reset";
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
}) {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockClear();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "hello" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await params.getReplyFromConfig(
{
Body: params.body,
From: "+1003",
To: "+2000",
CommandAuthorized: true,
},
{},
makeCfg(params.home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
expect(prompt).toContain("A new session was started via /new or /reset");
expect(prompt).toContain("Execute your Session Startup sequence now");
expect(prompt).toContain("read the required files before responding to the user");
}
export function installTriggerHandlingE2eTestHooks() {
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
resetProviderRuntimeHookCacheForTest();
vi.clearAllMocks();
});
}
export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
return runEmbeddedPiAgentMock;
}
export function createBlockReplyCollector() {
const blockReplies: Array<{ text?: string }> = [];
return {
blockReplies,
handlers: {
onBlockReply: async (payload: { text?: string }) => {
blockReplies.push(payload);
},
},
};
}

View file

@ -0,0 +1,71 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const BROWSER_FIXTURE_MANIFEST = {
id: "browser",
enabledByDefault: true,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
};
const BROWSER_FIXTURE_ENTRY = `module.exports = {
id: "browser",
name: "Browser",
description: "Bundled browser fixture plugin",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
register(api) {
api.registerTool((ctx) => ({
name: "browser",
label: "browser",
description: "browser fixture tool",
parameters: {
type: "object",
properties: {},
},
async execute() {
return {
content: [{ type: "text", text: "ok" }],
details: {
workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null,
},
};
},
}));
api.registerCli(({ program }) => {
program.command("browser");
}, { commands: ["browser"] });
api.registerGatewayMethod("browser.request", async () => ({ ok: true }), {
scope: "operator.write",
});
api.registerService({
id: "browser-control",
start() {},
});
},
};`;
export function createBundledBrowserPluginFixture(): { rootDir: string; cleanup: () => void } {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-browser-bundled-"));
const pluginDir = path.join(rootDir, "browser");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(BROWSER_FIXTURE_MANIFEST, null, 2),
"utf8",
);
fs.writeFileSync(path.join(pluginDir, "index.js"), BROWSER_FIXTURE_ENTRY, "utf8");
return {
rootDir,
cleanup() {
fs.rmSync(rootDir, { recursive: true, force: true });
},
};
}

View file

@ -0,0 +1,38 @@
import { expect, it } from "vitest";
type BundledChannelEntry = {
id: string;
kind?: string;
name: string;
};
type BundledChannelSetupEntry = {
kind?: string;
loadSetupPlugin?: unknown;
};
export function assertBundledChannelEntries(params: {
entry: BundledChannelEntry;
expectedId: string;
expectedName: string;
setupEntry: BundledChannelSetupEntry;
channelMessage?: string;
setupMessage?: string;
}) {
it(
params.channelMessage ?? "declares the channel plugin without importing the broad api barrel",
() => {
expect(params.entry.kind).toBe("bundled-channel-entry");
expect(params.entry.id).toBe(params.expectedId);
expect(params.entry.name).toBe(params.expectedName);
},
);
it(
params.setupMessage ?? "declares the setup plugin without importing the broad api barrel",
() => {
expect(params.setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(typeof params.setupEntry.loadSetupPlugin).toBe("function");
},
);
}

View file

@ -0,0 +1,59 @@
export const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`;
export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`;
export function bundledPluginRoot(pluginId: string): string {
return `${BUNDLED_PLUGIN_PATH_PREFIX}${pluginId}`;
}
export function bundledPluginFile(pluginId: string, relativePath: string): string {
return `${bundledPluginRoot(pluginId)}/${relativePath}`;
}
function joinRoot(baseDir: string, relativePath: string): string {
return `${baseDir.replace(/\/$/, "")}/${relativePath}`;
}
export function bundledPluginDirPrefix(pluginId: string, relativeDir: string): string {
return `${bundledPluginRoot(pluginId)}/${relativeDir.replace(/\/$/, "")}/`;
}
export function bundledPluginRootAt(baseDir: string, pluginId: string): string {
return joinRoot(baseDir, bundledPluginRoot(pluginId));
}
export function bundledPluginFileAt(
baseDir: string,
pluginId: string,
relativePath: string,
): string {
return joinRoot(baseDir, bundledPluginFile(pluginId, relativePath));
}
export function bundledDistPluginRoot(pluginId: string): string {
return `dist/${bundledPluginRoot(pluginId)}`;
}
export function bundledDistPluginFile(pluginId: string, relativePath: string): string {
return `${bundledDistPluginRoot(pluginId)}/${relativePath}`;
}
export function bundledDistPluginRootAt(baseDir: string, pluginId: string): string {
return joinRoot(baseDir, bundledDistPluginRoot(pluginId));
}
export function bundledDistPluginFileAt(
baseDir: string,
pluginId: string,
relativePath: string,
): string {
return joinRoot(baseDir, bundledDistPluginFile(pluginId, relativePath));
}
export function installedPluginRoot(baseDir: string, pluginId: string): string {
return bundledPluginRootAt(baseDir, pluginId);
}
export function repoInstallSpec(pluginId: string): string {
return `./${bundledPluginRoot(pluginId)}`;
}

View file

@ -0,0 +1,5 @@
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
"dist/extensions/discord/runtime-api.js",
"dist/extensions/slack/helper-api.js",
"dist/extensions/telegram/thread-bindings-runtime.js",
] as const;

View file

@ -0,0 +1,20 @@
export function createCapturedIo() {
let stdout = "";
let stderr = "";
return {
io: {
stdout: {
write(chunk: unknown) {
stdout += String(chunk);
},
},
stderr: {
write(chunk: unknown) {
stderr += String(chunk);
},
},
},
readStdout: () => stdout,
readStderr: () => stderr,
};
}

View file

@ -0,0 +1,34 @@
# 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

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

View file

@ -0,0 +1,213 @@
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();
});
});
}

View file

@ -0,0 +1,25 @@
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type IMessageContractSurface = {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS: string[];
resolveIMessageAttachmentRoots: (params: unknown) => string[];
resolveIMessageRemoteAttachmentRoots: (params: unknown) => string[];
};
const {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} = (await import(
resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "imessage",
artifactBasename: "contract-api.js",
})
)) as IMessageContractSurface;
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
};

View file

@ -0,0 +1,277 @@
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",
},
};
},
},
] 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

@ -0,0 +1,60 @@
import {
loadBundledPluginApiSync,
loadBundledPluginContractApiSync,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
type TelegramContractSurface = {
buildTelegramModelsProviderChannelData: (...args: unknown[]) => unknown;
};
type WhatsAppApiSurface = {
isWhatsAppGroupJid: (...args: unknown[]) => boolean;
normalizeWhatsAppTarget: (...args: unknown[]) => string | null;
whatsappCommandPolicy: Record<string, unknown>;
};
let telegramContractSurface: TelegramContractSurface | undefined;
let whatsappApiSurface: WhatsAppApiSurface | undefined;
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);
},
});
}
function getTelegramContractSurface(): TelegramContractSurface {
telegramContractSurface ??= loadBundledPluginContractApiSync<TelegramContractSurface>("telegram");
return telegramContractSurface;
}
function getWhatsAppApiSurface(): WhatsAppApiSurface {
whatsappApiSurface ??= loadBundledPluginApiSync<WhatsAppApiSurface>("whatsapp");
return whatsappApiSurface;
}
export const buildTelegramModelsProviderChannelData = (
...args: Parameters<TelegramContractSurface["buildTelegramModelsProviderChannelData"]>
) => getTelegramContractSurface().buildTelegramModelsProviderChannelData(...args);
export const isWhatsAppGroupJid = (...args: Parameters<WhatsAppApiSurface["isWhatsAppGroupJid"]>) =>
getWhatsAppApiSurface().isWhatsAppGroupJid(...args);
export const normalizeWhatsAppTarget = (
...args: Parameters<WhatsAppApiSurface["normalizeWhatsAppTarget"]>
) => getWhatsAppApiSurface().normalizeWhatsAppTarget(...args);
export const whatsappCommandPolicy = createLazyObjectSurface(
() => getWhatsAppApiSurface().whatsappCommandPolicy,
);

View file

@ -0,0 +1,193 @@
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

@ -0,0 +1,26 @@
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
export type SignalSender = {
kind: string;
raw: string;
e164?: string;
uuid?: string;
username?: string;
};
type SignalContractApiSurface = {
isSignalSenderAllowed: (...args: unknown[]) => boolean;
};
let signalContractSurface: Promise<SignalContractApiSurface> | undefined;
export function getSignalContractSurface(): Promise<SignalContractApiSurface> {
signalContractSurface ??= import(
resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "signal",
artifactBasename: "contract-api.js",
})
) as Promise<SignalContractApiSurface>;
return signalContractSurface;
}

View file

@ -0,0 +1,49 @@
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

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

View file

@ -0,0 +1,19 @@
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

@ -0,0 +1,28 @@
import { it } from "vitest";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type BuildFinalizedDiscordDirectInboundContext =
() => import("../../../src/auto-reply/templating.js").MsgContext;
const discordInboundContextHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "src/monitor/inbound-context.test-helpers.js",
});
async function getBuildFinalizedDiscordDirectInboundContext(): Promise<BuildFinalizedDiscordDirectInboundContext> {
const module = (await import(discordInboundContextHarnessModuleId)) as {
buildFinalizedDiscordDirectInboundContext: BuildFinalizedDiscordDirectInboundContext;
};
return module.buildFinalizedDiscordDirectInboundContext;
}
export function installDiscordInboundContractSuite() {
it("keeps inbound context finalized", async () => {
const buildContext = await getBuildFinalizedDiscordDirectInboundContext();
const ctx = buildContext();
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,32 @@
import { it } from "vitest";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
export function installSignalInboundContractSuite() {
it("keeps inbound context finalized", () => {
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",
RawBody: "hi",
CommandBody: "hi",
BodyForCommands: "hi",
From: "group:g1",
To: "group:g1",
SessionKey: "agent:main:signal:group:g1",
AccountId: "default",
ChatType: "group",
ConversationLabel: "Alice",
GroupSubject: "Test Group",
SenderName: "Alice",
SenderId: "+15550001111",
Provider: "signal",
Surface: "signal",
MessageSid: "1700000000000",
OriginatingChannel: "signal",
OriginatingTo: "group:g1",
CommandAuthorized: true,
});
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,108 @@
import { expect, it } from "vitest";
import type { MsgContext } from "../../../src/auto-reply/templating.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { withTempHome } from "../temp-home.js";
type ResolvedSlackAccount = {
accountId: string;
enabled: boolean;
botTokenSource: string;
appTokenSource: string;
userTokenSource: string;
config: {
replyToMode?: unknown;
replyToModeByChatType?: unknown;
dm?: unknown;
};
replyToMode?: unknown;
replyToModeByChatType?: unknown;
dm?: unknown;
};
type SlackMessageEvent = {
channel: string;
channel_type?: string;
user?: string;
text?: string;
ts: string;
};
type SlackPrepareResult = { ctxPayload: MsgContext } | null | undefined;
type SlackTestApi = {
createInboundSlackTestContext: (params: { cfg: OpenClawConfig }) => {
resolveUserName?: () => Promise<unknown>;
};
prepareSlackMessage: (params: {
ctx: {
resolveUserName?: () => Promise<unknown>;
};
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: string };
}) => Promise<SlackPrepareResult>;
};
const slackPrepareTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "inbound-contract-test-api.js",
});
let slackTestApiPromise: Promise<SlackTestApi> | undefined;
async function loadSlackTestApi(): Promise<SlackTestApi> {
slackTestApiPromise ??= import(slackPrepareTestApiModuleId) as Promise<SlackTestApi>;
return await slackTestApiPromise;
}
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
return {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
userTokenSource: "none",
config,
replyToMode: config.replyToMode,
replyToModeByChatType: config.replyToModeByChatType,
dm: config.dm,
};
}
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
return {
channel: "D123",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1.000",
...overrides,
} as SlackMessageEvent;
}
export function installSlackInboundContractSuite() {
it("keeps inbound context finalized", async () => {
await withTempHome(async () => {
const { createInboundSlackTestContext, prepareSlackMessage } = await loadSlackTestApi();
const ctx = createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
});
ctx.resolveUserName = async () => ({ name: "Alice" }) as never;
const prepared = await prepareSlackMessage({
ctx,
account: createSlackAccount(),
message: createSlackMessage({}),
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expectChannelInboundContextContract(prepared!.ctxPayload);
});
});
}

View file

@ -0,0 +1,63 @@
import { it } from "vitest";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
const telegramHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "telegram",
artifactBasename: "src/bot-message-context.test-harness.js",
});
async function buildTelegramMessageContextForTest(params: {
cfg: OpenClawConfig;
message: Record<string, unknown>;
}) {
const telegramHarnessModule = (await import(telegramHarnessModuleId)) as {
buildTelegramMessageContextForTest: (params: {
cfg: OpenClawConfig;
message: Record<string, unknown>;
}) => Promise<
{ ctxPayload: import("../../../src/auto-reply/templating.js").MsgContext } | null | undefined
>;
};
return await telegramHarnessModule.buildTelegramMessageContextForTest(params);
}
export function installTelegramInboundContractSuite() {
it("keeps inbound context finalized", async () => {
const context = await buildTelegramMessageContextForTest({
cfg: {
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies OpenClawConfig,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
});
const payload = context?.ctxPayload;
if (!payload) {
throw new Error("expected telegram inbound payload");
}
expectChannelInboundContextContract(payload);
});
}

View file

@ -0,0 +1,33 @@
import { it } from "vitest";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
export function installWhatsAppInboundContractSuite() {
it("keeps inbound context finalized", () => {
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",
RawBody: "hi",
CommandBody: "hi",
BodyForCommands: "hi",
From: "123@g.us",
To: "+15550001111",
SessionKey: "agent:main:whatsapp:group:123",
AccountId: "default",
ChatType: "group",
ConversationLabel: "123@g.us",
GroupSubject: "Test Group",
SenderName: "Alice",
SenderId: "alice@s.whatsapp.net",
SenderE164: "+15550002222",
Provider: "whatsapp",
Surface: "whatsapp",
MessageSid: "msg1",
OriginatingChannel: "whatsapp",
OriginatingTo: "123@g.us",
CommandAuthorized: true,
});
expectChannelInboundContextContract(ctx);
});
}

View file

@ -0,0 +1,78 @@
type ConversationBindingHelpers = {
requestConversationBinding: (...args: unknown[]) => unknown;
detachConversationBinding: (...args: unknown[]) => unknown;
getCurrentConversationBinding: (...args: unknown[]) => unknown;
};
type InteractiveHandlerRegistration<
TChannel extends string,
TContext,
> = ConversationBindingHelpers & {
channel: TChannel;
namespace: string;
handler: (ctx: TContext) => unknown;
};
type BaseInteractiveContext<TChannel extends string> = ConversationBindingHelpers & {
channel: TChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
senderId: string;
senderUsername?: string;
auth?: unknown;
};
export type TelegramInteractiveHandlerContext = BaseInteractiveContext<"telegram"> & {
callbackId: string;
senderUsername?: string;
threadId?: number;
isGroup?: boolean;
isForum?: boolean;
callback: {
data: string;
namespace: string;
payload: string;
messageId: number;
chatId: string;
messageText?: string;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type DiscordInteractiveHandlerContext = BaseInteractiveContext<"discord"> & {
interactionId: string;
guildId?: string;
interaction: {
data: string;
namespace: string;
payload: string;
[key: string]: unknown;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type SlackInteractiveHandlerContext = BaseInteractiveContext<"slack"> & {
interactionId: string;
threadId?: string;
interaction: {
data: string;
namespace: string;
payload: string;
[key: string]: unknown;
};
respond: Record<string, (...args: unknown[]) => unknown>;
};
export type TelegramInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"telegram",
TelegramInteractiveHandlerContext
>;
export type DiscordInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"discord",
DiscordInteractiveHandlerContext
>;
export type SlackInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"slack",
SlackInteractiveHandlerContext
>;

View file

@ -0,0 +1,23 @@
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

@ -0,0 +1,40 @@
import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type MatrixContractSurface = {
matrixSetupAdapter: Record<string, unknown>;
matrixSetupWizard: Record<string, unknown>;
};
let matrixContractSurface: MatrixContractSurface | undefined;
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);
},
});
}
function getMatrixContractSurface(): MatrixContractSurface {
matrixContractSurface ??= loadBundledPluginContractApiSync<MatrixContractSurface>("matrix");
return matrixContractSurface;
}
export const matrixSetupAdapter = createLazyObjectSurface(
() => getMatrixContractSurface().matrixSetupAdapter,
);
export const matrixSetupWizard = createLazyObjectSurface(
() => getMatrixContractSurface().matrixSetupWizard,
);

View file

@ -0,0 +1,409 @@
import { beforeEach, expect, it, type Mock, vi } from "vitest";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/reply-payload.js";
import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js";
import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => {
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
};
const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "src/outbound-adapter.js",
});
const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "outbound-payload-test-api.js",
});
const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "whatsapp",
artifactBasename: "outbound-payload-test-api.js",
});
let discordOutboundCache: Promise<ChannelOutboundAdapter> | undefined;
let slackTestApiPromise:
| Promise<{
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
}>
| undefined;
let whatsappTestApiPromise:
| Promise<{
whatsappOutbound: ChannelOutboundAdapter;
}>
| undefined;
async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
discordOutboundCache ??= (async () => {
const module = (await import(discordOutboundAdapterModuleId)) as {
discordOutbound: ChannelOutboundAdapter;
};
return module.discordOutbound;
})();
return await discordOutboundCache;
}
async function getCreateSlackOutboundPayloadHarness(): Promise<CreateSlackOutboundPayloadHarness> {
slackTestApiPromise ??= import(slackTestApiModuleId) as Promise<{
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
}>;
const { createSlackOutboundPayloadHarness } = await slackTestApiPromise;
return createSlackOutboundPayloadHarness;
}
async function getWhatsAppOutboundAsync(): Promise<ChannelOutboundAdapter> {
whatsappTestApiPromise ??= import(whatsappTestApiModuleId) as Promise<{
whatsappOutbound: ChannelOutboundAdapter;
}>;
const { whatsappOutbound } = await whatsappTestApiPromise;
return whatsappOutbound;
}
type PayloadHarnessParams = {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
};
type PayloadLike = {
mediaUrl?: string;
mediaUrls?: string[];
text?: string;
};
type SendResultLike = {
messageId: string;
[key: string]: unknown;
};
type ChunkingMode =
| {
longTextLength: number;
maxChunkLength: number;
mode: "split";
}
| {
longTextLength: number;
mode: "passthrough";
};
function installChannelOutboundPayloadContractSuite(params: {
channel: string;
chunking: ChunkingMode;
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) =>
| {
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
}
| Promise<{
run: () => Promise<Record<string, unknown>>;
sendMock: Mock;
to: string;
}>;
}) {
beforeEach(() => {
resetGlobalHookRunner();
});
it("text-only delegates to sendText", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: { text: "hello" },
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object));
expect(result).toMatchObject({ channel: params.channel });
});
it("single media delegates to sendMedia", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(
to,
"cap",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
);
expect(result).toMatchObject({ channel: params.channel });
});
it("multi-media iterates URLs with caption on first", async () => {
const { run, sendMock, to } = await params.createHarness({
payload: {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
},
sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }],
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock).toHaveBeenNthCalledWith(
1,
to,
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
);
expect(sendMock).toHaveBeenNthCalledWith(
2,
to,
"",
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
);
expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" });
});
it("empty payload returns no-op", async () => {
const { run, sendMock } = await params.createHarness({ payload: {} });
const result = await run();
expect(sendMock).not.toHaveBeenCalled();
expect(result).toEqual({ channel: params.channel, messageId: "" });
});
if (params.chunking.mode === "passthrough") {
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
const text = "a".repeat(params.chunking.longTextLength);
const { run, sendMock, to } = await params.createHarness({ payload: { text } });
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object));
expect(result).toMatchObject({ channel: params.channel });
});
return;
}
const chunking = params.chunking;
it("chunking splits long text", async () => {
const text = "a".repeat(chunking.longTextLength);
const { run, sendMock } = await params.createHarness({
payload: { text },
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
});
const result = await run();
expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of sendMock.mock.calls) {
expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength);
}
expect(result).toMatchObject({ channel: params.channel });
});
}
function buildChannelSendResult(channel: string, result: Record<string, unknown>) {
return {
channel,
messageId: typeof result.messageId === "string" ? result.messageId : "",
};
}
function createDiscordHarness(params: PayloadHarnessParams) {
const sendDiscord = vi.fn();
primeChannelOutboundSendMock(
sendDiscord,
{ messageId: "dc-1", channelId: "123456" },
params.sendResults,
);
const ctx = {
cfg: {},
to: "channel:123456",
text: "",
payload: params.payload,
deps: {
sendDiscord,
},
};
return {
run: async () => await (await getDiscordOutbound()).sendPayload!(ctx),
sendMock: sendDiscord,
to: ctx.to,
};
}
function createWhatsAppHarness(params: PayloadHarnessParams) {
const sendWhatsApp = vi.fn();
primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: params.payload,
deps: {
whatsapp: sendWhatsApp,
},
};
return {
run: async () => await (await getWhatsAppOutboundAsync()).sendPayload!(ctx),
sendMock: sendWhatsApp,
to: ctx.to,
};
}
function createDirectTextMediaHarness(params: PayloadHarnessParams) {
const sendFn = vi.fn();
primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults);
const outbound = createDirectTextMediaOutbound({
channel: "imessage",
resolveSender: () => sendFn,
resolveMaxBytes: () => undefined,
buildTextOptions: (opts) => opts as never,
buildMediaOptions: (opts) => opts as never,
});
const ctx = {
cfg: {},
to: "user1",
text: "",
payload: params.payload,
};
return {
run: async () => await outbound.sendPayload!(ctx),
sendMock: sendFn,
to: ctx.to,
};
}
function createZaloHarness(params: PayloadHarnessParams) {
const sendZalo = vi.fn();
primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "123456789",
text: "",
payload: params.payload,
};
return {
run: async () =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: 2000,
chunker: chunkTextForOutbound,
sendText: async (nextCtx) =>
buildChannelSendResult(
"zalo",
await sendZalo(nextCtx.to, nextCtx.text, {
accountId: undefined,
cfg: nextCtx.cfg,
}),
),
sendMedia: async (nextCtx) =>
buildChannelSendResult(
"zalo",
await sendZalo(nextCtx.to, nextCtx.text, {
accountId: undefined,
cfg: nextCtx.cfg,
mediaUrl: nextCtx.mediaUrl,
}),
),
emptyResult: { channel: "zalo", messageId: "" },
}),
sendMock: sendZalo,
to: ctx.to,
};
}
function createZalouserHarness(params: PayloadHarnessParams) {
const sendZalouser = vi.fn();
primeChannelOutboundSendMock(sendZalouser, { ok: true, messageId: "zlu-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "987654321",
text: "",
payload: params.payload,
};
return {
run: async () =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
sendText: async (nextCtx) => {
return buildChannelSendResult(
"zalouser",
await sendZalouser(nextCtx.to, nextCtx.text, {
profile: "default",
isGroup: false,
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
},
sendMedia: async (nextCtx) => {
return buildChannelSendResult(
"zalouser",
await sendZalouser(nextCtx.to, nextCtx.text, {
profile: "default",
isGroup: false,
mediaUrl: nextCtx.mediaUrl,
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
},
emptyResult: { channel: "zalouser", messageId: "" },
}),
sendMock: sendZalouser,
to: ctx.to,
};
}
export function installSlackOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "slack",
chunking: { mode: "passthrough", longTextLength: 5000 },
createHarness: async (params) => (await getCreateSlackOutboundPayloadHarness())(params),
});
}
export function installDiscordOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "discord",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: createDiscordHarness,
});
}
export function installWhatsAppOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "whatsapp",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness: createWhatsAppHarness,
});
}
export function installZaloOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "zalo",
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
createHarness: createZaloHarness,
});
}
export function installZalouserOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "zalouser",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: createZalouserHarness,
});
}
export function installDirectTextMediaOutboundPayloadContractSuite() {
installChannelOutboundPayloadContractSuite({
channel: "imessage",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness: createDirectTextMediaHarness,
});
}

View file

@ -0,0 +1,452 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type {
BaseProbeResult,
BaseTokenResolution,
ChannelDirectoryEntry,
} from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { LineProbeResult } from "../../../src/plugin-sdk/line.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { withEnvAsync } from "../../../src/test-utils/env.js";
type DiscordDirectoryContractApiSurface = {
listDiscordDirectoryPeersFromConfig: DirectoryListFn;
listDiscordDirectoryGroupsFromConfig: DirectoryListFn;
};
type DiscordProbe = BaseProbeResult;
type DiscordTokenResolution = BaseTokenResolution;
type IMessageProbe = BaseProbeResult;
type SignalProbe = BaseProbeResult;
type SlackDirectoryContractApiSurface = {
listSlackDirectoryPeersFromConfig: DirectoryListFn;
listSlackDirectoryGroupsFromConfig: DirectoryListFn;
};
type SlackProbe = BaseProbeResult;
type TelegramDirectoryContractApiSurface = {
listTelegramDirectoryPeersFromConfig: DirectoryListFn;
listTelegramDirectoryGroupsFromConfig: DirectoryListFn;
};
type TelegramProbe = BaseProbeResult;
type TelegramTokenResolution = BaseTokenResolution;
type WhatsAppDirectoryContractApiSurface = {
listWhatsAppDirectoryPeersFromConfig: DirectoryListFn;
listWhatsAppDirectoryGroupsFromConfig: DirectoryListFn;
};
let discordDirectoryContractApi: Promise<DiscordDirectoryContractApiSurface> | undefined;
let slackDirectoryContractApi: Promise<SlackDirectoryContractApiSurface> | undefined;
let telegramDirectoryContractApi: Promise<TelegramDirectoryContractApiSurface> | undefined;
let whatsappDirectoryContractApi: Promise<WhatsAppDirectoryContractApiSurface> | undefined;
async function importDirectoryContractApi<T extends object>(pluginId: string): Promise<T> {
const moduleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId,
artifactBasename: "directory-contract-api.js",
});
return (await import(moduleId)) as T;
}
function getDiscordDirectoryContractApi(): Promise<DiscordDirectoryContractApiSurface> {
discordDirectoryContractApi ??=
importDirectoryContractApi<DiscordDirectoryContractApiSurface>("discord");
return discordDirectoryContractApi;
}
function getSlackDirectoryContractApi(): Promise<SlackDirectoryContractApiSurface> {
slackDirectoryContractApi ??=
importDirectoryContractApi<SlackDirectoryContractApiSurface>("slack");
return slackDirectoryContractApi;
}
function getTelegramDirectoryContractApi(): Promise<TelegramDirectoryContractApiSurface> {
telegramDirectoryContractApi ??=
importDirectoryContractApi<TelegramDirectoryContractApiSurface>("telegram");
return telegramDirectoryContractApi;
}
function getWhatsAppDirectoryContractApi(): Promise<WhatsAppDirectoryContractApiSurface> {
whatsappDirectoryContractApi ??=
importDirectoryContractApi<WhatsAppDirectoryContractApiSurface>("whatsapp");
return whatsappDirectoryContractApi;
}
type DirectoryListFn = (params: {
cfg: OpenClawConfig;
accountId?: string;
query?: string | null;
limit?: number | null;
}) => Promise<ChannelDirectoryEntry[]>;
async function listDirectoryEntriesWithDefaults(listFn: DirectoryListFn, cfg: OpenClawConfig) {
return await listFn({
cfg,
accountId: "default",
query: null,
limit: null,
});
}
async function expectDirectoryIds(
listFn: DirectoryListFn,
cfg: OpenClawConfig,
expected: string[],
options?: { sorted?: boolean },
) {
const entries = await listDirectoryEntriesWithDefaults(listFn, cfg);
const ids = entries.map((entry) => entry.id);
expect(options?.sorted ? ids.toSorted() : ids).toEqual(expected);
}
export function describeDiscordPluginsCoreExtensionContract() {
describe("discord plugins-core extension contract", () => {
it("DiscordProbe satisfies BaseProbeResult", () => {
expectTypeOf<DiscordProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("Discord token resolution satisfies BaseTokenResolution", () => {
expectTypeOf<DiscordTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
});
it("lists peers/groups from config (numeric ids only)", async () => {
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
await getDiscordDirectoryContractApi();
const cfg = {
channels: {
discord: {
token: "discord-test",
dm: { allowFrom: ["<@111>", "<@!333>", "nope"] },
dms: { "222": {} },
guilds: {
"123": {
users: ["<@12345>", " discord:444 ", "not-an-id"],
channels: {
"555": {},
"<#777>": {},
"channel:666": {},
general: {},
},
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listDiscordDirectoryPeersFromConfig,
cfg,
["user:111", "user:12345", "user:222", "user:333", "user:444"],
{ sorted: true },
);
await expectDirectoryIds(
listDiscordDirectoryGroupsFromConfig,
cfg,
["channel:555", "channel:666", "channel:777"],
{
sorted: true,
},
);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
await getDiscordDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
discord: {
token: envSecret,
dm: { allowFrom: ["<@111>"] },
guilds: {
"123": {
channels: {
"555": {},
},
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listDiscordDirectoryGroupsFromConfig } = await getDiscordDirectoryContractApi();
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
"123": {
channels: {
"555": {},
"666": {},
"777": {},
},
},
},
},
},
} as unknown as OpenClawConfig;
const groups = await listDiscordDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "666",
limit: 5,
});
expect(groups.map((entry) => entry.id)).toEqual(["channel:666"]);
});
});
}
export function describeSlackPluginsCoreExtensionContract() {
describe("slack plugins-core extension contract", () => {
it("SlackProbe satisfies BaseProbeResult", () => {
expectTypeOf<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("lists peers/groups from config", async () => {
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
await getSlackDirectoryContractApi();
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] },
dms: { U234: {} },
channels: { C111: { users: ["U777"] } },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listSlackDirectoryPeersFromConfig,
cfg,
["user:u123", "user:u234", "user:u777", "user:u999"],
{ sorted: true },
);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
await getSlackDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
slack: {
botToken: envSecret,
appToken: envSecret,
dm: { allowFrom: ["U123"] },
channels: { C111: {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listSlackDirectoryPeersFromConfig } = await getSlackDirectoryContractApi();
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U100", "U200"] },
dms: { U300: {} },
},
},
} as unknown as OpenClawConfig;
const peers = await listSlackDirectoryPeersFromConfig({
cfg,
accountId: "default",
query: "user:u",
limit: 2,
});
expect(peers).toHaveLength(2);
expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true);
});
});
}
export function describeTelegramPluginsCoreExtensionContract() {
describe("telegram plugins-core extension contract", () => {
it("TelegramProbe satisfies BaseProbeResult", () => {
expectTypeOf<TelegramProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("Telegram token resolution satisfies BaseTokenResolution", () => {
expectTypeOf<TelegramTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
});
it("lists peers/groups from config", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
allowFrom: ["123", "alice", "tg:@bob"],
dms: { "456": {} },
groups: { "-1001": {}, "*": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listTelegramDirectoryPeersFromConfig,
cfg,
["123", "456", "@alice", "@bob"],
{
sorted: true,
},
);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("keeps fallback semantics when accountId is omitted", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
const cfg = {
channels: {
telegram: {
allowFrom: ["alice"],
groups: { "-1001": {} },
accounts: {
work: {
botToken: "tok-work",
allowFrom: ["bob"],
groups: { "-2002": {} },
},
},
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
await getTelegramDirectoryContractApi();
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
telegram: {
botToken: envSecret,
allowFrom: ["alice"],
groups: { "-1001": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listTelegramDirectoryGroupsFromConfig } = await getTelegramDirectoryContractApi();
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: { "-1001": {}, "-1002": {}, "-2001": {} },
},
},
} as unknown as OpenClawConfig;
const groups = await listTelegramDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "-100",
limit: 1,
});
expect(groups.map((entry) => entry.id)).toEqual(["-1001"]);
});
});
}
export function describeWhatsAppPluginsCoreExtensionContract() {
describe("whatsapp plugins-core extension contract", () => {
it("lists peers/groups from config", async () => {
const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } =
await getWhatsAppDirectoryContractApi();
const cfg = {
channels: {
whatsapp: {
allowFrom: ["+15550000000", "*", "123@g.us"],
groups: { "999@g.us": { requireMention: true }, "*": {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]);
await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const { listWhatsAppDirectoryGroupsFromConfig } = await getWhatsAppDirectoryContractApi();
const cfg = {
channels: {
whatsapp: {
groups: { "111@g.us": {}, "222@g.us": {}, "333@s.whatsapp.net": {} },
},
},
} as unknown as OpenClawConfig;
const groups = await listWhatsAppDirectoryGroupsFromConfig({
cfg,
accountId: "default",
query: "@g.us",
limit: 1,
});
expect(groups.map((entry) => entry.id)).toEqual(["111@g.us"]);
});
});
}
export function describeSignalPluginsCoreExtensionContract() {
describe("signal plugins-core extension contract", () => {
it("SignalProbe satisfies BaseProbeResult", () => {
expectTypeOf<SignalProbe>().toMatchTypeOf<BaseProbeResult>();
});
});
}
export function describeIMessagePluginsCoreExtensionContract() {
describe("imessage plugins-core extension contract", () => {
it("IMessageProbe satisfies BaseProbeResult", () => {
expectTypeOf<IMessageProbe>().toMatchTypeOf<BaseProbeResult>();
});
});
}
export function describeLinePluginsCoreExtensionContract() {
describe("line plugins-core extension contract", () => {
it("LineProbeResult satisfies BaseProbeResult", () => {
expectTypeOf<LineProbeResult>().toMatchTypeOf<BaseProbeResult>();
});
});
}

View file

@ -0,0 +1,213 @@
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
type ActionsContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "actions">;
unsupportedAction?: string;
cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedActions: string[];
expectedCapabilities?: string[];
beforeTest?: () => void;
}>;
};
let actionContractRegistryCache: ActionsContractEntry[] | undefined;
export function getActionContractRegistry(): ActionsContractEntry[] {
actionContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes default Slack actions",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
"pin",
"unpin",
"list-pins",
"member-info",
"emoji-list",
],
expectedCapabilities: ["blocks"],
},
{
name: "interactive replies add the shared interactive capability",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: {
interactiveReplies: true,
},
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
"pin",
"unpin",
"list-pins",
"member-info",
"emoji-list",
],
expectedCapabilities: ["blocks", "interactive"],
},
{
name: "missing tokens disables the actions surface",
cfg: {
channels: {
slack: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes send and react",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
expectedActions: ["send", "react"],
expectedCapabilities: ["buttons"],
},
{
name: "reactions can be disabled while send stays available",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
} as OpenClawConfig,
expectedActions: ["send"],
expectedCapabilities: ["buttons"],
},
{
name: "missing bot credentials disables the actions surface",
cfg: {
channels: {
mattermost: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
},
{
id: "telegram",
plugin: requireBundledChannelPlugin("telegram"),
cases: [
{
name: "exposes configured Telegram actions and capabilities",
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
},
},
} as OpenClawConfig,
expectedActions: [
"send",
"poll",
"react",
"delete",
"edit",
"topic-create",
"topic-edit",
],
expectedCapabilities: ["interactive", "buttons"],
},
],
},
{
id: "discord",
plugin: requireBundledChannelPlugin("discord"),
cases: [
{
name: "describes configured Discord actions and capabilities",
cfg: {
channels: {
discord: {
token: "Bot token-main",
actions: {
polls: true,
reactions: true,
permissions: false,
messages: false,
pins: false,
threads: false,
search: false,
stickers: false,
memberInfo: false,
roleInfo: false,
emojiUploads: false,
stickerUploads: false,
channelInfo: false,
channels: false,
voiceStatus: false,
events: false,
roles: false,
moderation: false,
presence: false,
},
},
},
} as OpenClawConfig,
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
expectedCapabilities: ["interactive", "components"],
},
],
},
];
return actionContractRegistryCache;
}

View file

@ -0,0 +1,238 @@
import { expect, it } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelSetupInput,
} from "../../../src/channels/plugins/types.core.js";
import type {
ChannelMessageActionName,
ChannelMessageCapability,
ChannelPlugin,
} from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
function sortStrings(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function resolveContractMessageDiscovery(params: {
plugin: Pick<ChannelPlugin, "actions">;
cfg: OpenClawConfig;
}) {
const actions = params.plugin.actions;
if (!actions) {
return {
actions: [] as ChannelMessageActionName[],
capabilities: [] as readonly ChannelMessageCapability[],
};
}
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
export function installChannelPluginContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
}) {
it("satisfies the base channel plugin contract", () => {
const { plugin } = params;
expect(typeof plugin.id).toBe("string");
expect(plugin.id.trim()).not.toBe("");
expect(plugin.meta.id).toBe(plugin.id);
expect(plugin.meta.label.trim()).not.toBe("");
expect(plugin.meta.selectionLabel.trim()).not.toBe("");
expect(plugin.meta.docsPath).toMatch(/^\/channels\//);
expect(plugin.meta.blurb.trim()).not.toBe("");
expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0);
expect(typeof plugin.config.listAccountIds).toBe("function");
expect(typeof plugin.config.resolveAccount).toBe("function");
});
}
type ChannelActionsContractCase = {
name: string;
cfg: OpenClawConfig;
expectedActions: readonly ChannelMessageActionName[];
expectedCapabilities?: readonly ChannelMessageCapability[];
beforeTest?: () => void;
};
export function installChannelActionsContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "actions">;
cases: readonly ChannelActionsContractCase[];
unsupportedAction?: ChannelMessageActionName;
}) {
it("exposes the base message actions contract", () => {
expect(params.plugin.actions).toBeDefined();
expect(typeof params.plugin.actions?.describeMessageTool).toBe("function");
});
for (const testCase of params.cases) {
it(`actions contract: ${testCase.name}`, () => {
testCase.beforeTest?.();
const discovery = resolveContractMessageDiscovery({
plugin: params.plugin,
cfg: testCase.cfg,
});
const actions = discovery.actions;
const capabilities = discovery.capabilities;
expect(actions).toEqual([...new Set(actions)]);
expect(capabilities).toEqual([...new Set(capabilities)]);
expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions));
expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? []));
if (params.plugin.actions?.supportsAction) {
for (const action of testCase.expectedActions) {
expect(params.plugin.actions.supportsAction({ action })).toBe(true);
}
if (
params.unsupportedAction &&
!testCase.expectedActions.includes(params.unsupportedAction)
) {
expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe(
false,
);
}
}
});
}
}
type ChannelSetupContractCase<ResolvedAccount> = {
name: string;
cfg: OpenClawConfig;
accountId?: string;
input: ChannelSetupInput;
expectedAccountId?: string;
expectedValidation?: string | null;
beforeTest?: () => void;
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void;
};
export function installChannelSetupContractSuite<ResolvedAccount>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
}) {
it("exposes the base setup contract", () => {
expect(params.plugin.setup).toBeDefined();
expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function");
});
for (const testCase of params.cases) {
it(`setup contract: ${testCase.name}`, () => {
testCase.beforeTest?.();
const resolvedAccountId =
params.plugin.setup?.resolveAccountId?.({
cfg: testCase.cfg,
accountId: testCase.accountId,
input: testCase.input,
}) ??
testCase.accountId ??
"default";
expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId);
const validation =
params.plugin.setup?.validateInput?.({
cfg: testCase.cfg,
accountId: resolvedAccountId,
input: testCase.input,
}) ?? null;
expect(validation).toBe(testCase.expectedValidation ?? null);
const nextCfg = params.plugin.setup?.applyAccountConfig({
cfg: testCase.cfg,
accountId: resolvedAccountId,
input: testCase.input,
});
expect(nextCfg).toBeDefined();
const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId);
testCase.assertPatchedConfig?.(nextCfg!);
testCase.assertResolvedAccount?.(account, nextCfg!);
});
}
}
type ChannelStatusContractCase<Probe> = {
name: string;
cfg: OpenClawConfig;
accountId?: string;
runtime?: ChannelAccountSnapshot;
probe?: Probe;
beforeTest?: () => void;
expectedState?: ChannelAccountState;
resolveStateInput?: {
configured: boolean;
enabled: boolean;
};
assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void;
assertSummary?: (summary: Record<string, unknown>) => void;
};
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
cases: readonly ChannelStatusContractCase<Probe>[];
}) {
it("exposes the base status contract", () => {
expect(params.plugin.status).toBeDefined();
expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function");
});
if (params.plugin.status?.defaultRuntime) {
it("status contract: default runtime is shaped like an account snapshot", () => {
expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string");
});
}
for (const testCase of params.cases) {
it(`status contract: ${testCase.name}`, async () => {
testCase.beforeTest?.();
const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId);
const snapshot = await params.plugin.status!.buildAccountSnapshot!({
account,
cfg: testCase.cfg,
runtime: testCase.runtime,
probe: testCase.probe,
});
expect(typeof snapshot.accountId).toBe("string");
expect(snapshot.accountId.trim()).not.toBe("");
testCase.assertSnapshot?.(snapshot);
if (params.plugin.status?.buildChannelSummary) {
const defaultAccountId =
params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default";
const summary = await params.plugin.status.buildChannelSummary({
account,
cfg: testCase.cfg,
defaultAccountId,
snapshot,
});
expect(summary).toEqual(expect.any(Object));
testCase.assertSummary?.(summary);
}
if (testCase.expectedState && params.plugin.status?.resolveAccountState) {
const state = params.plugin.status.resolveAccountState({
account,
cfg: testCase.cfg,
configured: testCase.resolveStateInput?.configured ?? true,
enabled: testCase.resolveStateInput?.enabled ?? true,
});
expect(state).toBe(testCase.expectedState);
}
});
}
}

View file

@ -0,0 +1,18 @@
import { listBundledChannelPlugins } from "../../../src/channels/plugins/bundled.js";
import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
type PluginContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
};
export function getPluginContractRegistry(): PluginContractEntry[] {
return listBundledChannelPlugins().map((plugin) => ({
id: plugin.id,
plugin: {
...plugin,
meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta }),
},
}));
}

View file

@ -0,0 +1,630 @@
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

@ -0,0 +1,227 @@
import { expect } from "vitest";
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
type SetupContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "config" | "setup">;
cases: Array<{
name: string;
cfg: OpenClawConfig;
accountId?: string;
input: Record<string, unknown>;
expectedAccountId?: string;
expectedValidation?: string | null;
beforeTest?: () => void;
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
assertResolvedAccount?: (account: unknown, cfg: OpenClawConfig) => void;
}>;
};
type StatusContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "config" | "status">;
cases: Array<{
name: string;
cfg: OpenClawConfig;
accountId?: string;
runtime?: Record<string, unknown>;
probe?: unknown;
beforeTest?: () => void;
assertSnapshot?: (snapshot: Record<string, unknown>) => void;
assertSummary?: (summary: Record<string, unknown>) => void;
}>;
};
let setupContractRegistryCache: SetupContractEntry[] | undefined;
let statusContractRegistryCache: StatusContractEntry[] | undefined;
export function getSetupContractRegistry(): SetupContractEntry[] {
setupContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
cases: [
{
name: "default account stores tokens and enables the channel",
cfg: {} as OpenClawConfig,
input: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.slack?.enabled).toBe(true);
expect(cfg.channels?.slack?.botToken).toBe("xoxb-test");
expect(cfg.channels?.slack?.appToken).toBe("xapp-test");
},
},
{
name: "non-default env setup is rejected",
cfg: {} as OpenClawConfig,
accountId: "ops",
input: {
useEnv: true,
},
expectedAccountId: "ops",
expectedValidation: "Slack env tokens can only be used for the default account.",
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
cases: [
{
name: "default account stores token and normalized base URL",
cfg: {} as OpenClawConfig,
input: {
botToken: "test-token",
httpUrl: "https://chat.example.com/",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.mattermost?.enabled).toBe(true);
expect(cfg.channels?.mattermost?.botToken).toBe("test-token");
expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com");
},
},
{
name: "missing credentials are rejected",
cfg: {} as OpenClawConfig,
input: {
httpUrl: "",
},
expectedAccountId: "default",
expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).",
},
],
},
{
id: "line",
plugin: requireBundledChannelPlugin("line"),
cases: [
{
name: "default account stores token and secret",
cfg: {} as OpenClawConfig,
input: {
channelAccessToken: "line-token",
channelSecret: "line-secret",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.line?.enabled).toBe(true);
expect(cfg.channels?.line?.channelAccessToken).toBe("line-token");
expect(cfg.channels?.line?.channelSecret).toBe("line-secret");
},
},
{
name: "non-default env setup is rejected",
cfg: {} as OpenClawConfig,
accountId: "ops",
input: {
useEnv: true,
},
expectedAccountId: "ops",
expectedValidation: "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
},
],
},
];
return setupContractRegistryCache;
}
export function getStatusContractRegistry(): StatusContractEntry[] {
statusContractRegistryCache ??= [
{
id: "slack",
plugin: requireBundledChannelPlugin("slack"),
cases: [
{
name: "configured account produces a configured status snapshot",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
running: true,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
},
},
],
},
{
id: "mattermost",
plugin: requireBundledChannelPlugin("mattermost"),
cases: [
{
name: "configured account preserves connectivity details in the snapshot",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
lastConnectedAt: 1234,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
expect(snapshot.connected).toBe(true);
expect(snapshot.baseUrl).toBe("https://chat.example.com");
},
},
],
},
{
id: "line",
plugin: requireBundledChannelPlugin("line"),
cases: [
{
name: "configured account produces a webhook status snapshot",
cfg: {
channels: {
line: {
enabled: true,
channelAccessToken: "line-token",
channelSecret: "line-secret",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
running: true,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
expect(snapshot.mode).toBe("webhook");
},
},
],
},
];
return statusContractRegistryCache;
}

View file

@ -0,0 +1,58 @@
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

@ -0,0 +1,146 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { SecurityAuditFinding } from "../../../src/security/audit.types.js";
import {
loadBundledPluginPublicSurfaceSync,
resolveRelativeBundledPluginPublicModuleId,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
type SecurityAuditAccount = {
accountId: string;
enabled?: boolean;
token?: unknown;
tokenSource?: string;
config?: Record<string, unknown>;
[key: string]: unknown;
};
type FlexibleSecurityAuditParams = {
cfg?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
account: SecurityAuditAccount;
accountId?: string | null;
orderedAccountIds?: string[];
hasExplicitAccountPath?: boolean;
};
type ConfigSecurityAuditParams = {
cfg: OpenClawConfig;
};
type AsyncChannelSecurityAuditCollector = (
params: FlexibleSecurityAuditParams,
) => Promise<SecurityAuditFinding[]>;
type SyncChannelSecurityAuditCollector = (
params: FlexibleSecurityAuditParams,
) => SecurityAuditFinding[];
type ConfigSecurityAuditCollector = (params: ConfigSecurityAuditParams) => SecurityAuditFinding[];
type DiscordSecurityAuditSurface = {
collectDiscordSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type FeishuSecuritySurface = {
collectFeishuSecurityAuditFindings: ConfigSecurityAuditCollector;
};
type SlackSecuritySurface = {
collectSlackSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type SynologyChatSecuritySurface = {
collectSynologyChatSecurityAuditFindings: SyncChannelSecurityAuditCollector;
};
type TelegramSecuritySurface = {
collectTelegramSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
};
type ZalouserSecuritySurface = {
collectZalouserSecurityAuditFindings: SyncChannelSecurityAuditCollector;
};
const discordSecurityAuditModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "discord",
artifactBasename: "security-audit-contract-api.js",
});
const slackSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "slack",
artifactBasename: "security-contract-api.js",
});
const telegramSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "telegram",
artifactBasename: "security-audit-contract-api.js",
});
let discordSecurityAuditSurfacePromise: Promise<DiscordSecurityAuditSurface> | undefined;
let slackSecuritySurfacePromise: Promise<SlackSecuritySurface> | undefined;
let telegramSecuritySurfacePromise: Promise<TelegramSecuritySurface> | undefined;
function loadDiscordSecurityAuditSurface(): Promise<DiscordSecurityAuditSurface> {
discordSecurityAuditSurfacePromise ??= import(
discordSecurityAuditModuleId
) as Promise<DiscordSecurityAuditSurface>;
return discordSecurityAuditSurfacePromise;
}
function loadFeishuSecuritySurface(): FeishuSecuritySurface {
return loadBundledPluginPublicSurfaceSync<FeishuSecuritySurface>({
pluginId: "feishu",
artifactBasename: "security-contract-api.js",
});
}
function loadSlackSecuritySurface(): Promise<SlackSecuritySurface> {
slackSecuritySurfacePromise ??= import(slackSecurityModuleId) as Promise<SlackSecuritySurface>;
return slackSecuritySurfacePromise;
}
function loadSynologyChatSecuritySurface(): SynologyChatSecuritySurface {
return loadBundledPluginPublicSurfaceSync<SynologyChatSecuritySurface>({
pluginId: "synology-chat",
artifactBasename: "contract-api.js",
});
}
function loadTelegramSecuritySurface(): Promise<TelegramSecuritySurface> {
telegramSecuritySurfacePromise ??= import(
telegramSecurityModuleId
) as Promise<TelegramSecuritySurface>;
return telegramSecuritySurfacePromise;
}
function loadZalouserSecuritySurface(): ZalouserSecuritySurface {
return loadBundledPluginPublicSurfaceSync<ZalouserSecuritySurface>({
pluginId: "zalouser",
artifactBasename: "contract-api.js",
});
}
export const collectDiscordSecurityAuditFindings: DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"] =
(async (...args) =>
(await loadDiscordSecurityAuditSurface()).collectDiscordSecurityAuditFindings(
...args,
)) as DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"];
export const collectFeishuSecurityAuditFindings: FeishuSecuritySurface["collectFeishuSecurityAuditFindings"] =
((...args) =>
loadFeishuSecuritySurface().collectFeishuSecurityAuditFindings(
...args,
)) as FeishuSecuritySurface["collectFeishuSecurityAuditFindings"];
export const collectSlackSecurityAuditFindings: SlackSecuritySurface["collectSlackSecurityAuditFindings"] =
(async (...args) =>
(await loadSlackSecuritySurface()).collectSlackSecurityAuditFindings(
...args,
)) as SlackSecuritySurface["collectSlackSecurityAuditFindings"];
export const collectSynologyChatSecurityAuditFindings: SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"] =
((...args) =>
loadSynologyChatSecuritySurface().collectSynologyChatSecurityAuditFindings(
...args,
)) as SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"];
export const collectTelegramSecurityAuditFindings: TelegramSecuritySurface["collectTelegramSecurityAuditFindings"] =
(async (...args) =>
(await loadTelegramSecuritySurface()).collectTelegramSecurityAuditFindings(
...args,
)) as TelegramSecuritySurface["collectTelegramSecurityAuditFindings"];
export const collectZalouserSecurityAuditFindings: ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"] =
((...args) =>
loadZalouserSecuritySurface().collectZalouserSecurityAuditFindings(
...args,
)) as ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"];

View file

@ -0,0 +1,88 @@
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

@ -0,0 +1,110 @@
import { vi } from "vitest";
import {
listBundledChannelPlugins,
setBundledChannelRuntime,
} from "../../../src/channels/plugins/bundled.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../../src/plugin-sdk/line.js";
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string {
return ["..", "..", "..", "extensions", pluginId, artifactBasename].join("/");
}
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 DirectoryContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "directory">;
coverage: "lookups" | "presence";
cfg?: OpenClawConfig;
accountId?: string;
};
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root",
roomId: to.replace(/^room:/, ""),
})),
);
setBundledChannelRuntime("line", {
channel: {
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
resolveLineAccount({ cfg, accountId }),
},
},
} as never);
vi.mock(buildBundledPluginModuleId("matrix", "runtime-api.js"), async () => {
const matrixRuntimeApiModuleId = buildBundledPluginModuleId("matrix", "runtime-api.js");
const actual = await vi.importActual(matrixRuntimeApiModuleId);
return {
...actual,
sendMessageMatrix: sendMessageMatrixMock,
};
});
let surfaceContractRegistryCache: SurfaceContractEntry[] | undefined;
let threadingContractRegistryCache: ThreadingContractEntry[] | undefined;
let directoryContractRegistryCache: DirectoryContractEntry[] | undefined;
export function getSurfaceContractRegistry(): SurfaceContractEntry[] {
surfaceContractRegistryCache ??= listBundledChannelPlugins().map((plugin) => ({
id: plugin.id,
plugin,
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
}));
return surfaceContractRegistryCache;
}
export function getThreadingContractRegistry(): ThreadingContractEntry[] {
threadingContractRegistryCache ??= getSurfaceContractRegistry()
.filter((entry) => entry.surfaces.includes("threading"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
}));
return threadingContractRegistryCache;
}
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
export function getDirectoryContractRegistry(): DirectoryContractEntry[] {
directoryContractRegistryCache ??= getSurfaceContractRegistry()
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
}));
return directoryContractRegistryCache;
}

View file

@ -0,0 +1,138 @@
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";
}) {
const { plugin, surface } = params;
it(`exposes the ${surface} surface contract`, () => {
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?.buildCrossContextComponents,
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

@ -0,0 +1,229 @@
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 { createNonExitingRuntime } from "../../../src/runtime.js";
const contractRuntime = createNonExitingRuntime();
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", () => {
expect(params.plugin.threading).toBeDefined();
});
it("keeps threading return values normalized", () => {
const threading = params.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 () => {
const directory = params.plugin.directory;
expect(directory).toBeDefined();
if (params.coverage === "presence") {
return;
}
const self = await directory?.self?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
runtime: contractRuntime,
});
if (self) {
expectDirectoryEntryShape(self);
}
const peers =
(await directory?.listPeers?.({
cfg: params.cfg ?? ({} as OpenClawConfig),
accountId: params.accountId ?? "default",
query: "",
limit: 5,
runtime: contractRuntime,
})) ?? [];
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: contractRuntime,
})) ?? [];
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: contractRuntime,
});
expect(Array.isArray(members)).toBe(true);
for (const member of members) {
expectDirectoryEntryShape(member);
}
}
});
}

View file

@ -0,0 +1,98 @@
import * as bundledChannelModule from "../../../src/channels/plugins/bundled.js";
import type {
ChannelConfigRuntimeSchema,
ChannelConfigSchema,
} from "../../../src/channels/plugins/types.plugin.js";
import { listBundledPluginMetadata } from "../../../src/plugins/bundled-plugin-metadata.js";
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
type BundledChannelPluginShape = {
id: string;
configSchema?: ChannelConfigSchema;
};
type BundledChannelMaps = {
runtimeMap: Map<string, ChannelConfigRuntimeSchema>;
configSchemaMap: Map<string, ChannelConfigSchema>;
};
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
function buildBundledChannelMaps(
plugins: readonly BundledChannelPluginShape[],
): BundledChannelMaps {
const runtimeMap = new Map<string, ChannelConfigRuntimeSchema>();
const configSchemaMap = new Map<string, ChannelConfigSchema>();
for (const plugin of plugins) {
const channelSchema = plugin.configSchema;
if (!channelSchema) {
continue;
}
configSchemaMap.set(plugin.id, channelSchema);
if (channelSchema.runtime) {
runtimeMap.set(plugin.id, channelSchema.runtime);
}
}
for (const entry of listBundledPluginMetadata({ includeChannelConfigs: true })) {
const channelConfigs = entry.manifest.channelConfigs;
if (!channelConfigs) {
continue;
}
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
if (!channelSchema) {
continue;
}
if (!configSchemaMap.has(channelId)) {
configSchemaMap.set(channelId, {
schema: channelSchema,
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
});
}
if (channelConfig.runtime && !runtimeMap.has(channelId)) {
runtimeMap.set(channelId, channelConfig.runtime);
}
}
}
return { runtimeMap, configSchemaMap };
}
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
try {
if (typeof bundledChannelModule.listBundledChannelPlugins !== "function") {
return undefined;
}
const plugins = bundledChannelModule.listBundledChannelPlugins();
return Array.isArray(plugins) ? (plugins as readonly BundledChannelPluginShape[]) : undefined;
} catch (error) {
if (error instanceof ReferenceError) {
return undefined;
}
throw error;
}
}
function getBundledChannelMaps(): BundledChannelMaps {
const plugins = readBundledChannelPlugins();
if (plugins && cachedBundledChannelMaps) {
return cachedBundledChannelMaps;
}
const maps = buildBundledChannelMaps(plugins ?? []);
if (plugins) {
cachedBundledChannelMaps = maps;
}
return maps;
}
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
return getBundledChannelMaps().runtimeMap;
}
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
return getBundledChannelMaps().configSchemaMap;
}

View file

@ -0,0 +1,139 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GENERATED_BASE_CONFIG_SCHEMA } from "../../../src/config/schema.base.generated.js";
export type ConfigHonorInventoryRow = {
key: string;
schemaPaths: string[];
typePaths: string[];
mergePaths: string[];
consumerPaths: string[];
reloadPaths: string[];
testPaths: string[];
notes?: string[];
};
type ConfigHonorProofKey =
| "schemaPaths"
| "typePaths"
| "mergePaths"
| "consumerPaths"
| "reloadPaths"
| "testPaths";
export type ConfigHonorAuditResult = {
schemaKeys: string[];
missingKeys: string[];
extraKeys: string[];
missingSchemaPaths: string[];
missingFiles: string[];
missingProofs: Array<{
key: string;
missing: ConfigHonorProofKey[];
}>;
};
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
function hasSchemaPath(schemaPath: string): boolean {
const segments = schemaPath.split(".");
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
for (const segment of segments) {
if (!current || typeof current !== "object") {
return false;
}
if (segment === "*") {
const items = (current as { items?: unknown }).items;
if (!items || typeof items !== "object") {
return false;
}
current = items;
continue;
}
const properties = (current as { properties?: Record<string, unknown> }).properties;
if (!properties || !Object.hasOwn(properties, segment)) {
return false;
}
current = properties[segment];
}
return true;
}
export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
const keys = new Set<string>();
for (const prefix of prefixes) {
const segments = prefix.split(".");
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
for (const segment of segments) {
if (!current || typeof current !== "object") {
current = null;
break;
}
if (segment === "*") {
current = (current as { items?: unknown }).items ?? null;
continue;
}
current = (current as { properties?: Record<string, unknown> }).properties?.[segment] ?? null;
}
const properties = (current as { properties?: Record<string, unknown> } | null)?.properties;
if (!properties) {
continue;
}
for (const key of Object.keys(properties)) {
keys.add(key);
}
}
return [...keys].toSorted();
}
export function auditConfigHonorInventory(params: {
prefixes: string[];
rows: ConfigHonorInventoryRow[];
expectedKeys?: string[];
repoRoot?: string;
}): ConfigHonorAuditResult {
const repoRoot = params.repoRoot ?? REPO_ROOT;
const schemaKeys = listSchemaLeafKeysForPrefixes(params.prefixes);
const expectedKeys = new Set(params.expectedKeys ?? schemaKeys);
const rowKeys = new Set(params.rows.map((row) => row.key));
const missingKeys = [...expectedKeys].filter((key) => !rowKeys.has(key)).toSorted();
const extraKeys = params.rows
.map((row) => row.key)
.filter((key) => !expectedKeys.has(key))
.toSorted();
const missingSchemaPaths = params.rows.flatMap((row) =>
row.schemaPaths.filter((schemaPath) => !hasSchemaPath(schemaPath)),
);
const missingFiles = params.rows.flatMap((row) => {
const files = [...row.typePaths, ...row.mergePaths, ...row.consumerPaths, ...row.testPaths];
return files
.filter((relativePath) => !fs.existsSync(path.join(repoRoot, relativePath)))
.map((relativePath) => `${row.key}:${relativePath}`);
});
const missingProofs = params.rows
.map((row) => {
const missing: ConfigHonorProofKey[] = [
row.schemaPaths.length === 0 ? "schemaPaths" : null,
row.typePaths.length === 0 ? "typePaths" : null,
row.mergePaths.length === 0 ? "mergePaths" : null,
row.consumerPaths.length === 0 ? "consumerPaths" : null,
row.reloadPaths.length === 0 ? "reloadPaths" : null,
row.testPaths.length === 0 ? "testPaths" : null,
].filter((value): value is ConfigHonorProofKey => value !== null);
return missing.length > 0 ? { key: row.key, missing } : null;
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return {
schemaKeys,
missingKeys,
extraKeys,
missingSchemaPaths,
missingFiles,
missingProofs,
};
}

View file

@ -0,0 +1,172 @@
import type { ConfigHonorInventoryRow } from "./config-honor-audit.js";
export const HEARTBEAT_CONFIG_PREFIXES = [
"agents.defaults.heartbeat",
"agents.list.*.heartbeat",
] as const;
export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
{
key: "every",
schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "model",
schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.model-override.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "prompt",
schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "includeSystemPromptSection",
schemaPaths: [
"agents.defaults.heartbeat.includeSystemPromptSection",
"agents.list.*.heartbeat.includeSystemPromptSection",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/agents/heartbeat-system-prompt.ts"],
consumerPaths: [
"src/agents/heartbeat-system-prompt.ts",
"src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts",
],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/agents/heartbeat-system-prompt.test.ts"],
},
{
key: "ackMaxChars",
schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"],
},
{
key: "suppressToolErrorWarnings",
schemaPaths: [
"agents.defaults.heartbeat.suppressToolErrorWarnings",
"agents.list.*.heartbeat.suppressToolErrorWarnings",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
},
{
key: "timeoutSeconds",
schemaPaths: [
"agents.defaults.heartbeat.timeoutSeconds",
"agents.list.*.heartbeat.timeoutSeconds",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/auto-reply/reply/get-reply.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/config/zod-schema.agent-defaults.test.ts",
"src/infra/heartbeat-runner.model-override.test.ts",
],
},
{
key: "lightContext",
schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.model-override.test.ts",
"src/agents/bootstrap-files.test.ts",
"src/gateway/config-reload.test.ts",
],
},
{
key: "isolatedSession",
schemaPaths: [
"agents.defaults.heartbeat.isolatedSession",
"agents.list.*.heartbeat.isolatedSession",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
},
{
key: "target",
schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/cron/service.main-job-passes-heartbeat-target-last.test.ts",
],
},
{
key: "to",
schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "accountId",
schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts",
],
},
{
key: "directPolicy",
schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
consumerPaths: ["src/infra/outbound/targets.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
{
key: "includeReasoning",
schemaPaths: [
"agents.defaults.heartbeat.includeReasoning",
"agents.list.*.heartbeat.includeReasoning",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
},
];

View file

@ -0,0 +1,17 @@
import type { ConfigUiHints } from "../../../src/config/schema.js";
// Keep this fixture minimal so redaction tests exercise the hint-matching
// behavior they care about without paying to build the full config schema graph.
export const redactSnapshotTestHints: ConfigUiHints = {
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
"broadcast.apiToken[]": { sensitive: true },
"env.GROQ_API_KEY": { sensitive: true },
"gateway.auth.password": { sensitive: true },
"models.providers.*.apiKey": { sensitive: true },
"models.providers.*.baseUrl": { sensitive: true },
"models.providers.*.request.headers.*": { sensitive: true },
"models.providers.*.request.auth.token": { sensitive: true },
"models.providers.*.request.proxy.url": { sensitive: true },
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
};

View file

@ -0,0 +1,188 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { clearAllBootstrapSnapshots } from "../../../src/agents/bootstrap-cache.js";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
import { createCronServiceState, type CronServiceDeps } from "../../../src/cron/service/state.js";
import type { CronJob, CronJobState } from "../../../src/cron/types.js";
import { resetAgentRunContextForTest } from "../../../src/infra/agent-events.js";
import {
resetCommandQueueStateForTest,
waitForActiveTasks,
} from "../../../src/process/command-queue.js";
import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
const TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1_000;
export const noopLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
trace: () => {},
};
export function setupCronRegressionFixtures(options?: { prefix?: string; baseTimeIso?: string }) {
let fixtureRoot = "";
let fixtureCount = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "cron-issues-"));
});
beforeEach(() => {
resetCommandQueueStateForTest();
useFrozenTime(options?.baseTimeIso ?? "2026-02-06T10:05:00.000Z");
});
afterEach(async () => {
vi.clearAllTimers();
vi.restoreAllMocks();
useRealTime();
await waitForActiveTasks(250);
resetCommandQueueStateForTest();
clearSessionStoreCacheForTest();
resetAgentRunContextForTest();
clearAllBootstrapSnapshots();
});
afterAll(async () => {
useRealTime();
await waitForActiveTasks(250);
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
return {
makeStorePath() {
return {
storePath: path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`),
};
},
};
}
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function createRunningCronServiceState(params: {
storePath: string;
log: CronServiceDeps["log"];
nowMs: () => number;
jobs: CronJob[];
}) {
const state = createCronServiceState({
cronEnabled: true,
storePath: params.storePath,
log: params.log,
nowMs: params.nowMs,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
});
state.running = true;
state.store = {
version: 1,
jobs: params.jobs,
};
return state;
}
export function topOfHourOffsetMs(jobId: string) {
const digest = crypto.createHash("sha256").update(jobId).digest();
return digest.readUInt32BE(0) % TOP_OF_HOUR_STAGGER_MS;
}
export function createDueIsolatedJob(params: {
id: string;
nowMs: number;
nextRunAtMs: number;
deleteAfterRun?: boolean;
}): CronJob {
return {
id: params.id,
name: params.id,
enabled: true,
deleteAfterRun: params.deleteAfterRun ?? false,
createdAtMs: params.nowMs,
updatedAtMs: params.nowMs,
schedule: { kind: "at", at: new Date(params.nextRunAtMs).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: params.id },
delivery: { mode: "none" },
state: { nextRunAtMs: params.nextRunAtMs },
};
}
export function createDefaultIsolatedRunner(): CronServiceDeps["runIsolatedAgentJob"] {
return vi.fn().mockResolvedValue({
status: "ok",
summary: "ok",
}) as CronServiceDeps["runIsolatedAgentJob"];
}
export function createAbortAwareIsolatedRunner(summary = "late") {
let observedAbortSignal: AbortSignal | undefined;
const started = createDeferred<void>();
const runIsolatedAgentJob = vi.fn(async ({ abortSignal }) => {
observedAbortSignal = abortSignal;
started.resolve();
await new Promise<void>((resolve) => {
if (!abortSignal) {
return;
}
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
return { status: "ok" as const, summary };
}) as CronServiceDeps["runIsolatedAgentJob"];
return {
runIsolatedAgentJob,
getObservedAbortSignal: () => observedAbortSignal,
waitForStart: () => started.promise,
};
}
export function createIsolatedRegressionJob(params: {
id: string;
name: string;
scheduledAt: number;
schedule: CronJob["schedule"];
payload: CronJob["payload"];
state?: CronJobState;
}): CronJob {
return {
id: params.id,
name: params.name,
enabled: true,
createdAtMs: params.scheduledAt - 86_400_000,
updatedAtMs: params.scheduledAt - 86_400_000,
schedule: params.schedule,
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: params.payload,
delivery: { mode: "announce" },
state: params.state ?? {},
};
}
export async function writeCronJobs(storePath: string, jobs: CronJob[]) {
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
}
export async function writeCronStoreSnapshot(storePath: string, jobs: unknown[]) {
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
}

View file

@ -0,0 +1,43 @@
import {
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../src/infra/format-time/format-datetime.js";
export { escapeRegExp } from "../../src/utils.js";
type EnvelopeTimestampZone = string;
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
const trimmedZone = zone.trim();
const normalized = trimmedZone.toLowerCase();
const weekday = (() => {
try {
if (normalized === "utc" || normalized === "gmt") {
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "short" }).format(date);
}
if (normalized === "local" || normalized === "host") {
return new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
}
return new Intl.DateTimeFormat("en-US", { timeZone: trimmedZone, weekday: "short" }).format(
date,
);
} catch {
return undefined;
}
})();
if (normalized === "utc" || normalized === "gmt") {
const ts = formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
if (normalized === "local" || normalized === "host") {
const ts = formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
const ts = formatZonedTimestamp(date, { timeZone: trimmedZone }) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
export function formatLocalEnvelopeTimestamp(date: Date): string {
return formatEnvelopeTimestamp(date, "local");
}

View file

@ -0,0 +1,17 @@
import { setTimeout as nativeSetTimeout } from "node:timers";
import { vi } from "vitest";
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= maxDelayMs) {
return nativeSetTimeout(handler, 0, ...args);
}
return nativeSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
return () => spy.mockRestore();
}

View file

@ -0,0 +1,378 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
export { extractFirstTextBlock };
export type ChatEventPayload = {
runId?: string;
sessionKey?: string;
state?: string;
message?: unknown;
};
export type GatewayInstance = {
name: string;
port: number;
hookToken: string;
gatewayToken: string;
homeDir: string;
stateDir: string;
configPath: string;
child: ChildProcessWithoutNullStreams;
stdout: string[];
stderr: string[];
};
const GATEWAY_START_TIMEOUT_MS = 60_000;
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
throw new Error("failed to bind ephemeral port");
}
await new Promise<void>((resolve) => srv.close(() => resolve()));
return addr.port;
};
async function waitForPortOpen(
proc: ChildProcessWithoutNullStreams,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
await sleep(10);
}
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`timeout waiting for gateway to listen on port ${port}\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const port = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`;
const gatewayToken = `gateway-${name}-${randomUUID()}`;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`));
const configDir = path.join(homeDir, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
const stateDir = path.join(configDir, "state");
const config = {
gateway: {
port,
auth: { mode: "token", token: gatewayToken },
controlUi: { enabled: false },
},
hooks: { enabled: true, token: hookToken, path: "/hooks" },
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const stdout: string[] = [];
const stderr: string[] = [];
let child: ChildProcessWithoutNullStreams | null = null;
try {
child = spawn(
"node",
[
"dist/index.js",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_GATEWAY_TOKEN: "",
OPENCLAW_GATEWAY_PASSWORD: "",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
VITEST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (d) => stdout.push(String(d)));
child.stderr?.on("data", (d) => stderr.push(String(d)));
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
return {
name,
port,
hookToken,
gatewayToken,
homeDir,
stateDir,
configPath,
child,
stdout,
stderr,
};
} catch (err) {
if (child && child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(homeDir, { recursive: true, force: true });
throw err;
}
}
export async function stopGatewayInstance(inst: GatewayInstance) {
if (inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGTERM");
} catch {
// ignore
}
}
const exited = await Promise.race([
new Promise<boolean>((resolve) => {
if (inst.child.exitCode !== null) {
return resolve(true);
}
inst.child.once("exit", () => resolve(true));
}),
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
]);
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(inst.homeDir, { recursive: true, force: true });
}
export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
const req = httpRequest(
{
method: "POST",
hostname: parsed.hostname,
port: Number(parsed.port),
path: `${parsed.pathname}${parsed.search}`,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let json: unknown = null;
if (data.trim()) {
try {
json = JSON.parse(data);
} catch {
json = data;
}
}
resolve({ status: res.statusCode ?? 0, json });
});
},
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: label,
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
timeoutMessage: `timeout waiting for ${label} to connect`,
});
return { client, nodeId };
}
async function connectStatusClient(
inst: GatewayInstance,
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
): Promise<GatewayClient> {
let settled = false;
let timer: NodeJS.Timeout | null = null;
return await new Promise<GatewayClient>((resolve, reject) => {
const finish = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
if (err) {
reject(err);
return;
}
resolve(client);
};
const client = new GatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
connectChallengeTimeoutMs: 0,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: `status-${inst.name}`,
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onHelloOk: () => {
finish();
},
onConnectError: (err) => finish(err),
onClose: (code, reason) => {
finish(new Error(`gateway closed (${code}): ${reason}`));
},
});
timer = setTimeout(() => {
finish(new Error("timeout waiting for node.list"));
}, timeoutMs);
client.start();
});
}
export async function waitForNodeStatus(
inst: GatewayInstance,
nodeId: string,
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
) {
const deadline = Date.now() + timeoutMs;
const client = await connectStatusClient(
inst,
Math.min(GATEWAY_CONNECT_STATUS_TIMEOUT_MS, timeoutMs),
);
try {
while (Date.now() < deadline) {
const list = await client.request("node.list", {});
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) {
return;
}
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
} finally {
client.stop();
}
throw new Error(`timeout waiting for node status for ${nodeId}`);
}
export async function waitForChatFinalEvent(params: {
events: ChatEventPayload[];
runId: string;
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>
evt.runId === params.runId && evt.sessionKey === params.sessionKey && evt.state === "final",
);
if (match) {
return match;
}
await sleep(20);
}
throw new Error(`timeout waiting for final chat event (runId=${params.runId})`);
}

View file

@ -0,0 +1,35 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
const rawObj = asRecord(raw);
const resolved = asRecord(rawObj.resolved);
if (Object.keys(resolved).length > 0) {
return resolved as OpenClawConfig;
}
const wrapped = asRecord(rawObj.config);
if (Object.keys(wrapped).length > 0) {
return wrapped as OpenClawConfig;
}
const legacyPayload = asRecord(rawObj.payload);
const legacyResolved = asRecord(legacyPayload.resolved);
if (Object.keys(legacyResolved).length > 0) {
return legacyResolved as OpenClawConfig;
}
const legacyConfig = asRecord(legacyPayload.config);
if (Object.keys(legacyConfig).length > 0) {
return legacyConfig as OpenClawConfig;
}
if (Object.keys(rawObj).length > 0 && !Object.prototype.hasOwnProperty.call(rawObj, "payload")) {
return rawObj as OpenClawConfig;
}
throw new Error("remote gateway config.get returned empty config payload");
}

View file

@ -0,0 +1,5 @@
import type { GatewayConnectionDetails } from "../../../src/gateway/call.js";
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
return details.urlSource !== "local loopback";
}

View file

@ -0,0 +1,18 @@
import { createServer, type RequestListener } from "node:http";
import type { AddressInfo } from "node:net";
export async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
try {
await fn(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

View file

@ -0,0 +1,8 @@
export async function importFreshModule<TModule>(
from: string,
specifier: string,
): Promise<TModule> {
// Vitest keys module instances by the full URL string, including the query
// suffix. These tests rely on that behavior to emulate code-split chunks.
return (await import(/* @vite-ignore */ new URL(specifier, from).href)) as TModule;
}

View file

@ -0,0 +1,269 @@
import { chunkMarkdownTextWithMode, chunkText } from "../../../src/auto-reply/chunk.js";
import { resolveChannelMediaMaxBytes } from "../../../src/channels/plugins/media-limits.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { sanitizeForPlainText } from "../../../src/plugin-sdk/outbound-runtime.js";
type SignalSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
const MB = 1024 * 1024;
function resolveSignalMaxBytes(cfg: OpenClawConfig, accountId?: string): number | undefined {
const signalCfg = cfg.channels?.signal as
| {
mediaMaxMb?: number;
accounts?: Record<string, { mediaMaxMb?: number }>;
}
| undefined;
const accountMb = accountId ? signalCfg?.accounts?.[accountId]?.mediaMaxMb : undefined;
const mediaMaxMb = accountMb ?? signalCfg?.mediaMaxMb;
return typeof mediaMaxMb === "number" ? mediaMaxMb * MB : undefined;
}
function resolveSignalSender(deps: OutboundSendDeps | undefined): SignalSendFn {
const sender = resolveOutboundSendDep<SignalSendFn>(deps, "signal");
if (!sender) {
throw new Error("missing sendSignal dep");
}
return sender;
}
function resolveSignalTextChunkLimit(cfg: OpenClawConfig, accountId?: string | null): number {
const signalCfg = cfg.channels?.signal as
| {
textChunkLimit?: number;
accounts?: Record<string, { textChunkLimit?: number }>;
}
| undefined;
const accountLimit = accountId ? signalCfg?.accounts?.[accountId]?.textChunkLimit : undefined;
if (typeof accountLimit === "number") {
return accountLimit;
}
return typeof signalCfg?.textChunkLimit === "number" ? signalCfg.textChunkLimit : 4000;
}
function withSignalChannel(result: Awaited<ReturnType<SignalSendFn>>) {
return {
channel: "signal" as const,
...result,
};
}
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
const limit = resolveSignalTextChunkLimit(cfg, accountId);
const chunks = chunkMarkdownTextWithMode(text, limit, "length");
const outputChunks = chunks.length === 0 && text ? [text] : chunks;
const results = [];
for (const chunk of outputChunks) {
abortSignal?.throwIfAborted();
results.push(
withSignalChannel(
await send(to, chunk, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
}),
),
);
}
return results;
},
sendFormattedMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
}) => {
abortSignal?.throwIfAborted();
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
mediaLocalRoots,
mediaReadFile,
}),
);
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
mediaReadFile,
}),
);
},
};
type WhatsAppSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
function resolveWhatsAppSender(deps: OutboundSendDeps | undefined): WhatsAppSendFn {
const sender = resolveOutboundSendDep<WhatsAppSendFn>(deps, "whatsapp");
if (!sender) {
throw new Error("missing whatsapp dep");
}
return sender;
}
function withWhatsAppChannel(result: Awaited<ReturnType<WhatsAppSendFn>>) {
return {
channel: "whatsapp" as const,
...result,
};
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
}) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
};
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
const sender = resolveOutboundSendDep<
(
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string; chatId?: string }>
>(deps, "imessage");
if (!sender) {
throw new Error("missing sendIMessage dep");
}
return sender;
}
function withIMessageChannel(
result: Awaited<ReturnType<ReturnType<typeof resolveIMessageSender>>>,
) {
return {
channel: "imessage" as const,
...result,
};
}
function resolveIMessageMaxBytes(
cfg: OpenClawConfig,
accountId?: string | null,
): number | undefined {
return resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
}
export const imessageOutboundForTest: ChannelOutboundAdapter = {
deliveryMode: "direct",
sanitizeText: ({ text }) => text,
sendText: async ({ to, text, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
accountId: accountId ?? undefined,
}),
),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
mediaUrl,
mediaLocalRoots,
mediaReadFile,
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
accountId: accountId ?? undefined,
}),
),
};

View file

@ -0,0 +1,99 @@
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../../../src/channels/plugins/types.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { createOutboundTestPlugin } from "../../../src/test-utils/channel-plugins.js";
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
type HeartbeatSendFn = (
to: string,
text: string,
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
return {
deliveryMode: "direct",
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
if (!send) {
throw new Error(`Missing ${channelId} outbound send dependency`);
}
const baseOptions = {
verbose: false,
cfg,
accountId,
};
const sendOptions =
channelId === "telegram"
? {
...baseOptions,
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
}
: {
...baseOptions,
...opts,
...(replyToId ? { replyToId } : {}),
...(threadId !== undefined ? { threadId } : {}),
};
return (await send(to, text, sendOptions)) as never;
},
};
}
function createHeartbeatChannelPlugin(params: {
id: HeartbeatSendChannelId;
label: string;
docsPath: string;
heartbeat?: ChannelPlugin["heartbeat"];
}): ChannelPlugin {
return {
...createOutboundTestPlugin({
id: params.id as ChannelId,
label: params.label,
docsPath: params.docsPath,
outbound: createHeartbeatOutboundAdapter(params.id),
}),
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
};
}
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
});
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
});
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
docsPath: "/channels/whatsapp",
heartbeat: {
checkReady: async ({ cfg, deps }) => {
if (cfg.web?.enabled === false) {
return { ok: false, reason: "whatsapp-disabled" };
}
const authExists = await (deps?.webAuthExists ?? (async () => true))();
if (!authExists) {
return { ok: false, reason: "whatsapp-not-linked" };
}
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
if (!listenerActive) {
return { ok: false, reason: "whatsapp-not-running" };
}
return { ok: true, reason: "ok" };
},
},
});

View file

@ -0,0 +1,15 @@
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type BundledPluginEntryModule = {
default: {
register(api: OpenClawPluginApi): void;
};
};
export function loadBundledProviderPlugin(pluginId: string): BundledPluginEntryModule["default"] {
return loadBundledPluginPublicSurfaceSync<BundledPluginEntryModule>({
pluginId,
artifactBasename: "index.js",
}).default;
}

View file

@ -0,0 +1,118 @@
import type { VideoGenerationResult } from "openclaw/plugin-sdk/video-generation";
import { expect, vi } from "vitest";
type ClearableMock = {
mockClear(): unknown;
};
type ResettableMock = {
mockReset(): unknown;
};
type ResolvableMock = {
mockResolvedValue(value: unknown): unknown;
};
type ChainableResolvedValueMock = ResettableMock & {
mockResolvedValueOnce(value: unknown): ChainableResolvedValueMock;
};
export type DashscopeVideoProviderMocks = {
resolveApiKeyForProviderMock: ClearableMock;
postJsonRequestMock: ResettableMock & ResolvableMock;
fetchWithTimeoutMock: ChainableResolvedValueMock;
assertOkOrThrowHttpErrorMock: ClearableMock;
resolveProviderHttpRequestConfigMock: ClearableMock;
};
export function resetDashscopeVideoProviderMocks(mocks: DashscopeVideoProviderMocks): void {
mocks.resolveApiKeyForProviderMock.mockClear();
mocks.postJsonRequestMock.mockReset();
mocks.fetchWithTimeoutMock.mockReset();
mocks.assertOkOrThrowHttpErrorMock.mockClear();
mocks.resolveProviderHttpRequestConfigMock.mockClear();
}
export function mockSuccessfulDashscopeVideoTask(
mocks: Pick<DashscopeVideoProviderMocks, "postJsonRequestMock" | "fetchWithTimeoutMock">,
params: {
requestId?: string;
taskId?: string;
taskStatus?: string;
videoUrl?: string;
} = {},
): void {
const {
requestId = "req-1",
taskId = "task-1",
taskStatus = "SUCCEEDED",
videoUrl = "https://example.com/out.mp4",
} = params;
mocks.postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: requestId,
output: {
task_id: taskId,
},
}),
},
release: vi.fn(async () => {}),
});
mocks.fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
output: {
task_status: taskStatus,
results: [{ video_url: videoUrl }],
},
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
});
}
export function expectDashscopeVideoTaskPoll(
fetchWithTimeoutMock: ChainableResolvedValueMock,
params: {
baseUrl?: string;
taskId?: string;
timeoutMs?: number;
} = {},
): void {
const {
baseUrl = "https://dashscope-intl.aliyuncs.com",
taskId = "task-1",
timeoutMs = 120_000,
} = params;
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
`${baseUrl}/api/v1/tasks/${taskId}`,
expect.objectContaining({ method: "GET" }),
timeoutMs,
fetch,
);
}
export function expectSuccessfulDashscopeVideoResult(
result: VideoGenerationResult,
params: {
requestId?: string;
taskId?: string;
taskStatus?: string;
} = {},
): void {
const { requestId = "req-1", taskId = "task-1", taskStatus = "SUCCEEDED" } = params;
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual(
expect.objectContaining({
requestId,
taskId,
taskStatus,
}),
);
}

View file

@ -0,0 +1,68 @@
import { expect } from "vitest";
import { listSupportedMusicGenerationModes } from "../../../src/music-generation/capabilities.js";
import type {
MusicGenerationProviderPlugin,
VideoGenerationProviderPlugin,
} from "../../../src/plugins/types.js";
import { listSupportedVideoGenerationModes } from "../../../src/video-generation/capabilities.js";
export function expectExplicitVideoGenerationCapabilities(
provider: VideoGenerationProviderPlugin,
): void {
expect(
provider.capabilities.generate,
`${provider.id} missing generate capabilities`,
).toBeDefined();
expect(
provider.capabilities.imageToVideo,
`${provider.id} missing imageToVideo capabilities`,
).toBeDefined();
expect(
provider.capabilities.videoToVideo,
`${provider.id} missing videoToVideo capabilities`,
).toBeDefined();
const supportedModes = listSupportedVideoGenerationModes(provider);
const imageToVideo = provider.capabilities.imageToVideo;
const videoToVideo = provider.capabilities.videoToVideo;
if (imageToVideo?.enabled) {
expect(
imageToVideo.maxInputImages ?? 0,
`${provider.id} imageToVideo.enabled requires maxInputImages`,
).toBeGreaterThan(0);
expect(supportedModes).toContain("imageToVideo");
}
if (videoToVideo?.enabled) {
expect(
videoToVideo.maxInputVideos ?? 0,
`${provider.id} videoToVideo.enabled requires maxInputVideos`,
).toBeGreaterThan(0);
expect(supportedModes).toContain("videoToVideo");
}
}
export function expectExplicitMusicGenerationCapabilities(
provider: MusicGenerationProviderPlugin,
): void {
expect(
provider.capabilities.generate,
`${provider.id} missing generate capabilities`,
).toBeDefined();
expect(provider.capabilities.edit, `${provider.id} missing edit capabilities`).toBeDefined();
const edit = provider.capabilities.edit;
if (!edit) {
return;
}
if (edit.enabled) {
expect(
edit.maxInputImages ?? 0,
`${provider.id} edit.enabled requires maxInputImages`,
).toBeGreaterThan(0);
expect(listSupportedMusicGenerationModes(provider)).toContain("edit");
} else {
expect(listSupportedMusicGenerationModes(provider)).toEqual(["generate"]);
}
}

View file

@ -0,0 +1,57 @@
import type { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
import { afterEach, vi } from "vitest";
type ResolveProviderHttpRequestConfigParams = Parameters<
typeof resolveProviderHttpRequestConfig
>[0];
const providerHttpMocks = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params: ResolveProviderHttpRequestConfigParams) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: providerHttpMocks.resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: ({
label,
timeoutMs,
}: {
label: string;
timeoutMs?: number;
}) => ({
label,
timeoutMs,
}),
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
postJsonRequest: providerHttpMocks.postJsonRequestMock,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
waitProviderOperationPollInterval: async () => {},
}));
export function getProviderHttpMocks() {
return providerHttpMocks;
}
export function installProviderHttpMockCleanup(): void {
afterEach(() => {
providerHttpMocks.resolveApiKeyForProviderMock.mockClear();
providerHttpMocks.postJsonRequestMock.mockReset();
providerHttpMocks.fetchWithTimeoutMock.mockReset();
providerHttpMocks.assertOkOrThrowHttpErrorMock.mockClear();
providerHttpMocks.resolveProviderHttpRequestConfigMock.mockClear();
});
}

View file

@ -0,0 +1,156 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { ImageGenerationProvider } from "../../../src/image-generation/types.js";
import type { MusicGenerationProvider } from "../../../src/music-generation/types.js";
import type { VideoGenerationProvider } from "../../../src/video-generation/types.js";
import { resetGenerationRuntimeMocks } from "./runtime-test-mocks.js";
type ModelRef = { provider: string; model: string };
const mediaRuntimeMocks = vi.hoisted(() => {
const debug = vi.fn();
const warn = vi.fn();
const parseGenerationModelRef = (raw?: string): ModelRef | undefined => {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
return undefined;
}
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1),
};
};
return {
createSubsystemLogger: vi.fn(() => ({ debug, warn: vi.fn() })),
describeFailoverError: vi.fn(),
getImageGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => ImageGenerationProvider | undefined
>(() => undefined),
getMusicGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => MusicGenerationProvider | undefined
>(() => undefined),
getProviderEnvVars: vi.fn<(providerId: string) => string[]>(() => []),
getVideoGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => VideoGenerationProvider | undefined
>(() => undefined),
isFailoverError: vi.fn<(err: unknown) => boolean>(() => false),
listImageGenerationProviders: vi.fn<(config?: OpenClawConfig) => ImageGenerationProvider[]>(
() => [],
),
listMusicGenerationProviders: vi.fn<(config?: OpenClawConfig) => MusicGenerationProvider[]>(
() => [],
),
listVideoGenerationProviders: vi.fn<(config?: OpenClawConfig) => VideoGenerationProvider[]>(
() => [],
),
parseImageGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
parseMusicGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
parseVideoGenerationModelRef:
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
listProfilesForProvider: vi.fn(() => []),
resolveEnvApiKey: vi.fn(() => undefined),
resolveAgentModelFallbackValues: vi.fn<(value: unknown) => string[]>(() => []),
resolveAgentModelPrimaryValue: vi.fn<(value: unknown) => string | undefined>(() => undefined),
resolveProviderAuthEnvVarCandidates: vi.fn(() => ({})),
debug,
warn,
};
});
vi.mock("../../../src/agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mediaRuntimeMocks.ensureAuthProfileStore,
listProfilesForProvider: mediaRuntimeMocks.listProfilesForProvider,
}));
vi.mock("../../../src/agents/defaults.js", () => ({
DEFAULT_PROVIDER: "openai",
}));
vi.mock("../../../src/agents/failover-error.js", () => ({
describeFailoverError: mediaRuntimeMocks.describeFailoverError,
isFailoverError: mediaRuntimeMocks.isFailoverError,
}));
vi.mock("../../../src/agents/model-auth-env.js", () => ({
resolveEnvApiKey: mediaRuntimeMocks.resolveEnvApiKey,
}));
vi.mock("../../../src/config/model-input.js", () => ({
resolveAgentModelFallbackValues: mediaRuntimeMocks.resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue: mediaRuntimeMocks.resolveAgentModelPrimaryValue,
}));
vi.mock("../../../src/logging/subsystem.js", () => ({
createSubsystemLogger: mediaRuntimeMocks.createSubsystemLogger,
}));
vi.mock("../../../src/secrets/provider-env-vars.js", () => ({
getProviderEnvVars: mediaRuntimeMocks.getProviderEnvVars,
resolveProviderAuthEnvVarCandidates: mediaRuntimeMocks.resolveProviderAuthEnvVarCandidates,
}));
vi.mock("../../../src/image-generation/model-ref.js", () => ({
parseImageGenerationModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
}));
vi.mock("../../../src/image-generation/provider-registry.js", () => ({
getImageGenerationProvider: mediaRuntimeMocks.getImageGenerationProvider,
listImageGenerationProviders: mediaRuntimeMocks.listImageGenerationProviders,
}));
vi.mock("../../../src/music-generation/model-ref.js", () => ({
parseMusicGenerationModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
}));
vi.mock("../../../src/music-generation/provider-registry.js", () => ({
getMusicGenerationProvider: mediaRuntimeMocks.getMusicGenerationProvider,
listMusicGenerationProviders: mediaRuntimeMocks.listMusicGenerationProviders,
}));
vi.mock("../../../src/video-generation/model-ref.js", () => ({
parseVideoGenerationModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
}));
vi.mock("../../../src/video-generation/provider-registry.js", () => ({
getVideoGenerationProvider: mediaRuntimeMocks.getVideoGenerationProvider,
listVideoGenerationProviders: mediaRuntimeMocks.listVideoGenerationProviders,
}));
export function getMediaGenerationRuntimeMocks() {
return mediaRuntimeMocks;
}
export function resetImageGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getImageGenerationProvider,
listProviders: mediaRuntimeMocks.listImageGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
});
}
export function resetMusicGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getMusicGenerationProvider,
listProviders: mediaRuntimeMocks.listMusicGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
});
}
export function resetVideoGenerationRuntimeMocks(): void {
resetSharedRuntimeImportMocks();
resetGenerationRuntimeMocks({
...mediaRuntimeMocks,
getProvider: mediaRuntimeMocks.getVideoGenerationProvider,
listProviders: mediaRuntimeMocks.listVideoGenerationProviders,
parseModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
});
}
function resetSharedRuntimeImportMocks(): void {
mediaRuntimeMocks.ensureAuthProfileStore.mockReset();
mediaRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
mediaRuntimeMocks.listProfilesForProvider.mockReset();
mediaRuntimeMocks.listProfilesForProvider.mockReturnValue([]);
mediaRuntimeMocks.resolveEnvApiKey.mockReset();
mediaRuntimeMocks.resolveEnvApiKey.mockReturnValue(undefined);
}

View file

@ -0,0 +1,45 @@
type ClearableMock = {
mockClear(): unknown;
};
type ResettableMock = {
mockReset(): unknown;
};
type ResettableReturnMock = ResettableMock & {
mockReturnValue(value: unknown): unknown;
};
export type GenerationRuntimeMocks = {
createSubsystemLogger: ClearableMock;
describeFailoverError: ResettableMock;
getProvider: ResettableReturnMock;
getProviderEnvVars: ResettableReturnMock;
resolveProviderAuthEnvVarCandidates: ResettableReturnMock;
isFailoverError: ResettableReturnMock;
listProviders: ResettableReturnMock;
parseModelRef: ClearableMock;
resolveAgentModelFallbackValues: ResettableReturnMock;
resolveAgentModelPrimaryValue: ResettableReturnMock;
debug: ResettableMock;
};
export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void {
mocks.createSubsystemLogger.mockClear();
mocks.describeFailoverError.mockReset();
mocks.getProvider.mockReset();
mocks.getProviderEnvVars.mockReset();
mocks.getProviderEnvVars.mockReturnValue([]);
mocks.resolveProviderAuthEnvVarCandidates.mockReset();
mocks.resolveProviderAuthEnvVarCandidates.mockReturnValue({});
mocks.isFailoverError.mockReset();
mocks.isFailoverError.mockReturnValue(false);
mocks.listProviders.mockReset();
mocks.listProviders.mockReturnValue([]);
mocks.parseModelRef.mockClear();
mocks.resolveAgentModelFallbackValues.mockReset();
mocks.resolveAgentModelFallbackValues.mockReturnValue([]);
mocks.resolveAgentModelPrimaryValue.mockReset();
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
mocks.debug.mockReset();
}

View file

@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
export function createMockIncomingRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
};
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
return req;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { mockNodeBuiltinModule } from "./node-builtin-mocks.js";
describe("mockNodeBuiltinModule", () => {
it("merges partial overrides into the original module", async () => {
const actual = { readFileSync: () => "actual", watch: () => "watch" };
const readFileSync = () => "mock";
const mocked = await mockNodeBuiltinModule(async () => actual, {
readFileSync,
});
expect(mocked.readFileSync).toBe(readFileSync);
expect(mocked.watch).toBe(actual.watch);
expect("default" in mocked).toBe(false);
});
it("mirrors overrides into the default export when requested", async () => {
const homedir = () => "/tmp/home";
const mocked = await mockNodeBuiltinModule(
async () => ({ tmpdir: () => "/tmp" }),
{ homedir },
{ mirrorToDefault: true },
);
expect(mocked.default).toMatchObject({
homedir,
tmpdir: expect.any(Function),
});
});
it("preserves existing default exports while overriding members", async () => {
const actual = {
readFileSync: () => "actual",
default: {
readFileSync: () => "actual",
statSync: () => "stat",
},
};
const readFileSync = () => "mock";
const mocked = await mockNodeBuiltinModule(
async () => actual,
{ readFileSync },
{ mirrorToDefault: true },
);
expect(mocked.default).toMatchObject({
readFileSync,
statSync: expect.any(Function),
});
});
});

View file

@ -0,0 +1,43 @@
type MockFactory<TModule extends object> =
| Partial<TModule>
| ((actual: TModule) => Partial<TModule>);
function resolveMockOverrides<TModule extends object>(
actual: TModule,
factory: MockFactory<TModule>,
): Partial<TModule> {
return typeof factory === "function" ? factory(actual) : factory;
}
function resolveDefaultBase(actual: object): Record<string, unknown> {
const defaultExport = (actual as { default?: unknown }).default;
if (defaultExport && typeof defaultExport === "object") {
return defaultExport as Record<string, unknown>;
}
return actual as Record<string, unknown>;
}
export async function mockNodeBuiltinModule<TModule extends object>(
loadActual: () => Promise<TModule>,
factory: MockFactory<TModule>,
options?: { mirrorToDefault?: boolean },
): Promise<TModule> {
const actual = await loadActual();
const overrides = resolveMockOverrides(actual, factory);
const mocked = {
...actual,
...overrides,
} as TModule & { default?: Record<string, unknown> };
if (!options?.mirrorToDefault) {
return mocked;
}
return {
...mocked,
default: {
...resolveDefaultBase(actual),
...overrides,
},
} as TModule;
}

View file

@ -0,0 +1,9 @@
import { stripAnsi } from "../../src/terminal/ansi.js";
export function normalizeTestText(input: string): string {
return stripAnsi(input)
.replaceAll("\r\n", "\n")
.replaceAll("…", "...")
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
.replace(/[\uD800-\uDFFF]/g, "?");
}

View file

@ -0,0 +1,24 @@
import { expect } from "vitest";
export function extractPairingCode(text: string): string {
const code = text.match(/Pairing code:\s*```[\r\n]+([A-Z2-9]{6,})/)?.[1];
expect(code).toBeDefined();
return code ?? "";
}
export function expectPairingReplyText(
text: string,
params: {
channel: string;
idLine: string;
code?: string;
},
): string {
const code = params.code ?? extractPairingCode(text);
expect(text).toContain("OpenClaw: access not configured.");
expect(text).toContain(params.idLine);
expect(text).toContain("Pairing code:");
expect(text).toContain(`\n\`\`\`\n${code}\n\`\`\`\n`);
expect(text).toContain(`pairing approve ${params.channel} ${code}`);
return code;
}

View file

@ -0,0 +1,16 @@
import path from "node:path";
export function isPathWithinBase(base: string, target: string): boolean {
if (process.platform === "win32") {
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
}
const normalizedBase = path.resolve(base);
const normalizedTarget = path.resolve(target);
const rel = path.relative(normalizedBase, normalizedTarget);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

View file

@ -0,0 +1,23 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export function createPatternFileHelper(prefix: string) {
const tempDirs = new Set<string>();
return {
cleanup() {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs.clear();
},
writePatternFile(basename: string, value: unknown) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.add(dir);
const filePath = path.join(dir, basename);
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
return filePath;
},
};
}

View file

@ -0,0 +1,107 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import { createPluginRegistry, type PluginRecord } from "../../../src/plugins/registry.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createPluginRecord } from "../../../src/plugins/status.test-helpers.js";
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
export {
registerProviderPlugins as registerProviders,
requireRegisteredProvider as requireProvider,
} from "../../../src/test-utils/plugin-registration.js";
export function uniqueSortedStrings(values: readonly string[]) {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
function formatImportSideEffectCall(args: readonly unknown[]): string {
if (args.length === 0) {
return "(no args)";
}
return args
.map((arg) => {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
})
.join(", ");
}
export function assertNoImportTimeSideEffects(params: {
moduleId: string;
forbiddenSeam: string;
calls: readonly (readonly unknown[])[];
why: string;
fixHint: string;
}) {
if (params.calls.length === 0) {
return;
}
const observedCalls = params.calls
.slice(0, 3)
.map((call, index) => ` ${index + 1}. ${formatImportSideEffectCall(call)}`)
.join("\n");
throw new Error(
[
`[runtime contract] ${params.moduleId} touched ${params.forbiddenSeam} during module import.`,
`why this is banned: ${params.why}`,
`expected fix: ${params.fixHint}`,
`observed calls (${params.calls.length}):`,
observedCalls,
].join("\n"),
);
}
export function createPluginRegistryFixture(config = {} as OpenClawConfig) {
return {
config,
registry: createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
}),
};
}
export function registerTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
record: PluginRecord;
register(api: OpenClawPluginApi): void;
}) {
params.registry.registry.plugins.push(params.record);
params.register(
params.registry.createApi(params.record, {
config: params.config,
}),
);
}
export function registerVirtualTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
id: string;
name: string;
source?: string;
kind?: PluginRecord["kind"];
contracts?: PluginRecord["contracts"];
register(this: void, api: OpenClawPluginApi): void;
}) {
registerTestPlugin({
registry: params.registry,
config: params.config,
record: createPluginRecord({
id: params.id,
name: params.name,
source: params.source ?? `/virtual/${params.id}/index.ts`,
...(params.kind ? { kind: params.kind } : {}),
...(params.contracts ? { contracts: params.contracts } : {}),
}),
register: params.register,
});
}

View file

@ -0,0 +1,25 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const SHARED_IMPORT_ENV = {
HOME: process.env.HOME,
NODE_OPTIONS: process.env.NODE_OPTIONS,
NODE_PATH: process.env.NODE_PATH,
PATH: process.env.PATH,
TERM: process.env.TERM,
} satisfies NodeJS.ProcessEnv;
export async function runDirectImportSmoke(code: string): Promise<string> {
const { stdout } = await execFileAsync(process.execPath, ["--import", "tsx", "-e", code], {
cwd: repoRoot,
env: SHARED_IMPORT_ENV,
timeout: 40_000,
});
return stdout;
}

View file

@ -0,0 +1,33 @@
import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract";
type DirectorySurface = {
listPeers: NonNullable<ChannelDirectoryAdapter["listPeers"]>;
listGroups: NonNullable<ChannelDirectoryAdapter["listGroups"]>;
};
export function createDirectoryTestRuntime() {
return {
log: () => {},
error: () => {},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
}
export function expectDirectorySurface(directory: unknown): DirectorySurface {
if (!directory || typeof directory !== "object") {
throw new Error("expected directory");
}
const { listPeers, listGroups } = directory as ChannelDirectoryAdapter;
if (!listPeers) {
throw new Error("expected listPeers");
}
if (!listGroups) {
throw new Error("expected listGroups");
}
return {
listPeers,
listGroups,
};
}

View file

@ -0,0 +1 @@
export { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";

View file

@ -0,0 +1,238 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
const JITI_EXTENSIONS = [
".ts",
".tsx",
".mts",
".cts",
".mtsx",
".ctsx",
".js",
".mjs",
".cjs",
".json",
] as const;
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
type SourceModuleRef = {
specifier: string;
typeOnly: boolean;
};
function listPluginSdkExportedSubpaths(root: string): string[] {
const packageJsonPath = path.join(root, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
exports?: Record<string, unknown>;
};
return Object.keys(packageJson.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length));
}
function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null {
const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`);
if (existsSync(distCandidate)) {
return distCandidate;
}
for (const ext of SOURCE_MODULE_EXTENSIONS) {
const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`);
if (existsSync(srcCandidate)) {
return srcCandidate;
}
}
return null;
}
function resolveLocalModulePath(filePath: string, specifier: string): string | null {
const basePath = path.resolve(path.dirname(filePath), specifier);
const candidates = new Set<string>([basePath]);
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(`${basePath}${ext}`);
}
if (/\.[cm]?[jt]sx?$/u.test(basePath)) {
const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, "");
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(`${withoutExt}${ext}`);
}
}
for (const ext of SOURCE_MODULE_EXTENSIONS) {
candidates.add(path.join(basePath, `index${ext}`));
}
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
function collectSourceModuleRefs(filePath: string): SourceModuleRef[] {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const refs: SourceModuleRef[] = [];
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
const specifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (specifier) {
refs.push({
specifier,
typeOnly: Boolean(statement.importClause?.isTypeOnly),
});
}
continue;
}
if (!ts.isExportDeclaration(statement)) {
continue;
}
const specifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!specifier) {
continue;
}
const typeOnly = Boolean(
statement.isTypeOnly ||
(statement.exportClause &&
ts.isNamedExports(statement.exportClause) &&
statement.exportClause.elements.length > 0 &&
statement.exportClause.elements.every((element) => element.isTypeOnly)),
);
refs.push({ specifier, typeOnly });
}
return refs;
}
function collectPluginSdkAliases(params: {
modulePath: string;
root: string;
realPluginSdkSpecifiers?: readonly string[];
}): Record<string, string> {
const realSpecifiers = new Set<string>();
const stubSpecifiers = new Set<string>();
const visitedFiles = new Set<string>();
const stubPath = path.join(params.root, "test", "helpers", "plugins", "plugin-sdk-stub.cjs");
const explicitRealSpecifiers = new Set(params.realPluginSdkSpecifiers ?? []);
function visitModule(filePath: string, rootModule: boolean): void {
if (visitedFiles.has(filePath)) {
return;
}
visitedFiles.add(filePath);
for (const ref of collectSourceModuleRefs(filePath)) {
if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
const shouldKeepReal =
rootModule &&
!ref.typeOnly &&
(explicitRealSpecifiers.size === 0 || explicitRealSpecifiers.has(ref.specifier));
if (shouldKeepReal) {
realSpecifiers.add(ref.specifier);
const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
const target = resolvePluginSdkAliasTarget(params.root, subpath);
if (target?.endsWith(".ts")) {
visitModule(target, false);
}
} else {
stubSpecifiers.add(ref.specifier);
}
continue;
}
if (!ref.specifier.startsWith(".")) {
continue;
}
const resolved = resolveLocalModulePath(filePath, ref.specifier);
if (resolved) {
visitModule(resolved, false);
}
}
}
visitModule(params.modulePath, true);
const aliasEntries = new Map<string, string>();
for (const specifier of listPluginSdkExportedSubpaths(params.root).map(
(subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`,
)) {
if (realSpecifiers.has(specifier)) {
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath);
continue;
}
if (stubSpecifiers.has(specifier)) {
aliasEntries.set(specifier, stubPath);
}
}
return Object.fromEntries(aliasEntries);
}
export function loadRuntimeApiExportTypesViaJiti(params: {
modulePath: string;
exportNames: readonly string[];
additionalAliases?: Record<string, string>;
realPluginSdkSpecifiers?: readonly string[];
}): Record<string, string> {
const root = process.cwd();
const alias = {
...collectPluginSdkAliases({
modulePath: params.modulePath,
root,
realPluginSdkSpecifiers: params.realPluginSdkSpecifiers,
}),
...params.additionalAliases,
};
const script = `
import path from "node:path";
import { createJiti } from "jiti";
const modulePath = ${JSON.stringify(params.modulePath)};
const exportNames = ${JSON.stringify(params.exportNames)};
const alias = ${JSON.stringify(alias)};
const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), {
interopDefault: true,
tryNative: false,
fsCache: false,
moduleCache: false,
extensions: ${JSON.stringify(JITI_EXTENSIONS)},
alias,
});
const mod = jiti(modulePath);
console.log(
JSON.stringify(
Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])),
),
);
`;
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
cwd: root,
encoding: "utf-8",
});
return JSON.parse(raw) as Record<string, string>;
}

View file

@ -0,0 +1 @@
export { createRequestCaptureJsonFetch } from "../../../src/media-understanding/audio.test-helpers.js";

View file

@ -0,0 +1 @@
export { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";

View file

@ -0,0 +1,46 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { ModelApi } from "../../../src/config/types.models.js";
export const EXPECTED_FALLBACKS = ["anthropic/claude-opus-4-5"] as const;
export function createLegacyProviderConfig(params: {
providerId: string;
api: ModelApi;
modelId?: string;
modelName?: string;
baseUrl?: string;
apiKey?: string;
}): OpenClawConfig {
return {
models: {
providers: {
[params.providerId]: {
baseUrl: params.baseUrl ?? "https://old.example.com",
apiKey: params.apiKey ?? "old-key",
api: params.api,
models: [
{
id: params.modelId ?? "old-model",
name: params.modelName ?? "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
} as OpenClawConfig;
}
export function createConfigWithFallbacks(): OpenClawConfig {
return {
agents: {
defaults: {
model: { fallbacks: [...EXPECTED_FALLBACKS] },
},
},
};
}

View file

@ -0,0 +1,106 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isAtLeast, parseSemver } from "../../../src/infra/runtime-guard.js";
import { parseMinHostVersionRequirement } from "../../../src/plugins/min-host-version.js";
import { bundledPluginFile } from "../bundled-plugin-paths.js";
type PackageManifest = {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
install?: {
minHostVersion?: string;
};
};
};
type PackageManifestContractParams = {
pluginId: string;
pluginLocalRuntimeDeps?: string[];
mirroredRootRuntimeDeps?: string[];
minHostVersionBaseline?: string;
};
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe package manifest shape.
function readJson<T>(relativePath: string): T {
const absolutePath = path.resolve(process.cwd(), relativePath);
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T;
}
export function describePackageManifestContract(params: PackageManifestContractParams) {
const packagePath = bundledPluginFile(params.pluginId, "package.json");
describe(`${params.pluginId} package manifest contract`, () => {
if (params.pluginLocalRuntimeDeps?.length) {
for (const dependencyName of params.pluginLocalRuntimeDeps) {
it(`keeps ${dependencyName} plugin-local`, () => {
const rootManifest = readJson("package.json") as PackageManifest;
const pluginManifest = readJson(packagePath) as PackageManifest;
const pluginSpec =
pluginManifest.dependencies?.[dependencyName] ??
pluginManifest.optionalDependencies?.[dependencyName];
const rootSpec =
rootManifest.dependencies?.[dependencyName] ??
rootManifest.optionalDependencies?.[dependencyName];
expect(pluginSpec).toBeTruthy();
expect(rootSpec).toBeUndefined();
});
}
}
if (params.mirroredRootRuntimeDeps?.length) {
for (const dependencyName of params.mirroredRootRuntimeDeps) {
it(`mirrors ${dependencyName} at the root package`, () => {
const rootManifest = readJson<PackageManifest>("package.json");
const pluginManifest = readJson<PackageManifest>(packagePath);
const pluginSpec =
pluginManifest.dependencies?.[dependencyName] ??
pluginManifest.optionalDependencies?.[dependencyName];
const rootSpec =
rootManifest.dependencies?.[dependencyName] ??
rootManifest.optionalDependencies?.[dependencyName];
expect(pluginSpec).toBeTruthy();
expect(rootSpec).toBe(pluginSpec);
});
}
}
const minHostVersionBaseline = params.minHostVersionBaseline;
if (minHostVersionBaseline) {
it("declares a parseable minHostVersion floor at or above the baseline", () => {
const baseline = parseSemver(minHostVersionBaseline);
expect(baseline).not.toBeNull();
if (!baseline) {
return;
}
const manifest = readJson<PackageManifest>(packagePath);
const requirement = parseMinHostVersionRequirement(
manifest.openclaw?.install?.minHostVersion ?? null,
);
expect(
requirement,
`${packagePath} should declare openclaw.install.minHostVersion`,
).not.toBeNull();
if (!requirement) {
return;
}
const minimum = parseSemver(requirement.minimumLabel);
expect(minimum, `${packagePath} should use a parseable semver floor`).not.toBeNull();
if (!minimum) {
return;
}
expect(
isAtLeast(minimum, baseline),
`${packagePath} should require at least OpenClaw ${minHostVersionBaseline}`,
).toBe(true);
});
}
});
}

View file

@ -0,0 +1,58 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
type TestPluginApiInput = Partial<OpenClawPluginApi>;
export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPluginApi {
return {
id: "test-plugin",
name: "test-plugin",
source: "test",
registrationMode: "full",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerTool() {},
registerHook() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerCliBackend() {},
registerTextTransforms() {},
registerService() {},
registerReload() {},
registerNodeHostCommand() {},
registerSecurityAuditCollector() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerProvider() {},
registerSpeechProvider() {},
registerRealtimeTranscriptionProvider() {},
registerRealtimeVoiceProvider() {},
registerMediaUnderstandingProvider() {},
registerImageGenerationProvider() {},
registerMusicGenerationProvider() {},
registerVideoGenerationProvider() {},
registerWebFetchProvider() {},
registerWebSearchProvider() {},
registerInteractiveHandler() {},
onConversationBindingResolved() {},
registerCommand() {},
registerContextEngine() {},
registerCompactionProvider() {},
registerAgentHarness() {},
registerDetachedTaskRuntime() {},
registerMemoryCapability() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryCorpusSupplement() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},
resolvePath(input: string) {
return input;
},
on() {},
...api,
};
}

View file

@ -0,0 +1,137 @@
import { describePluginRegistrationContract } from "./plugin-registration-contract.js";
type PluginRegistrationContractParams = Parameters<typeof describePluginRegistrationContract>[0];
export const pluginRegistrationContractCases = {
anthropic: {
pluginId: "anthropic",
providerIds: ["anthropic"],
mediaUnderstandingProviderIds: ["anthropic"],
cliBackendIds: ["claude-cli"],
requireDescribeImages: true,
},
brave: {
pluginId: "brave",
webSearchProviderIds: ["brave"],
},
comfy: {
pluginId: "comfy",
providerIds: ["comfy"],
imageGenerationProviderIds: ["comfy"],
musicGenerationProviderIds: ["comfy"],
videoGenerationProviderIds: ["comfy"],
requireGenerateImage: true,
requireGenerateVideo: true,
},
deepgram: {
pluginId: "deepgram",
mediaUnderstandingProviderIds: ["deepgram"],
},
duckduckgo: {
pluginId: "duckduckgo",
webSearchProviderIds: ["duckduckgo"],
},
elevenlabs: {
pluginId: "elevenlabs",
speechProviderIds: ["elevenlabs"],
requireSpeechVoices: true,
},
exa: {
pluginId: "exa",
webSearchProviderIds: ["exa"],
},
fal: {
pluginId: "fal",
providerIds: ["fal"],
imageGenerationProviderIds: ["fal"],
},
firecrawl: {
pluginId: "firecrawl",
webFetchProviderIds: ["firecrawl"],
webSearchProviderIds: ["firecrawl"],
toolNames: ["firecrawl_search", "firecrawl_scrape"],
},
google: {
pluginId: "google",
providerIds: ["google", "google-gemini-cli"],
webSearchProviderIds: ["gemini"],
speechProviderIds: ["google"],
mediaUnderstandingProviderIds: ["google"],
imageGenerationProviderIds: ["google"],
requireDescribeImages: true,
requireGenerateImage: true,
},
groq: {
pluginId: "groq",
mediaUnderstandingProviderIds: ["groq"],
},
microsoft: {
pluginId: "microsoft",
speechProviderIds: ["microsoft"],
requireSpeechVoices: true,
},
minimax: {
pluginId: "minimax",
providerIds: ["minimax", "minimax-portal"],
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
imageGenerationProviderIds: ["minimax", "minimax-portal"],
requireDescribeImages: true,
requireGenerateImage: true,
},
mistral: {
pluginId: "mistral",
mediaUnderstandingProviderIds: ["mistral"],
},
moonshot: {
pluginId: "moonshot",
providerIds: ["moonshot"],
webSearchProviderIds: ["kimi"],
mediaUnderstandingProviderIds: ["moonshot"],
requireDescribeImages: true,
manifestAuthChoice: {
pluginId: "kimi",
choiceId: "kimi-code-api-key",
choiceLabel: "Kimi Code API key (subscription)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi K2.5)",
groupHint: "Kimi K2.5",
},
},
openai: {
pluginId: "openai",
providerIds: ["openai", "openai-codex"],
speechProviderIds: ["openai"],
realtimeTranscriptionProviderIds: ["openai"],
realtimeVoiceProviderIds: ["openai"],
mediaUnderstandingProviderIds: ["openai", "openai-codex"],
imageGenerationProviderIds: ["openai"],
requireSpeechVoices: true,
requireDescribeImages: true,
requireGenerateImage: true,
},
openrouter: {
pluginId: "openrouter",
providerIds: ["openrouter"],
mediaUnderstandingProviderIds: ["openrouter"],
requireDescribeImages: true,
},
perplexity: {
pluginId: "perplexity",
webSearchProviderIds: ["perplexity"],
},
tavily: {
pluginId: "tavily",
webSearchProviderIds: ["tavily"],
toolNames: ["tavily_search", "tavily_extract"],
},
xai: {
pluginId: "xai",
providerIds: ["xai"],
webSearchProviderIds: ["grok"],
},
zai: {
pluginId: "zai",
mediaUnderstandingProviderIds: ["zai"],
requireDescribeImages: true,
},
} satisfies Record<string, PluginRegistrationContractParams>;

View file

@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { pluginRegistrationContractRegistry } from "../../../src/plugins/contracts/registry.js";
import { loadPluginManifestRegistry } from "../../../src/plugins/manifest-registry.js";
type PluginRegistrationContractParams = {
pluginId: string;
cliBackendIds?: string[];
providerIds?: string[];
webFetchProviderIds?: string[];
webSearchProviderIds?: string[];
speechProviderIds?: string[];
realtimeTranscriptionProviderIds?: string[];
realtimeVoiceProviderIds?: string[];
mediaUnderstandingProviderIds?: string[];
imageGenerationProviderIds?: string[];
videoGenerationProviderIds?: string[];
musicGenerationProviderIds?: string[];
toolNames?: string[];
requireSpeechVoices?: boolean;
requireDescribeImages?: boolean;
requireGenerateImage?: boolean;
requireGenerateVideo?: boolean;
manifestAuthChoice?: {
pluginId: string;
choiceId: string;
choiceLabel: string;
groupId: string;
groupLabel: string;
groupHint: string;
};
};
function findRegistration(pluginId: string) {
const entry = pluginRegistrationContractRegistry.find(
(candidate) => candidate.pluginId === pluginId,
);
if (!entry) {
throw new Error(`plugin registration contract missing for ${pluginId}`);
}
return entry;
}
export function describePluginRegistrationContract(params: PluginRegistrationContractParams) {
describe(`${params.pluginId} plugin registration contract`, () => {
if (params.cliBackendIds) {
it("keeps bundled cli-backend ownership explicit", () => {
expect(findRegistration(params.pluginId).cliBackendIds).toEqual(params.cliBackendIds);
});
}
if (params.providerIds) {
it("keeps bundled provider ownership explicit", () => {
expect(findRegistration(params.pluginId).providerIds).toEqual(params.providerIds);
});
}
if (params.webSearchProviderIds) {
it("keeps bundled web search ownership explicit", () => {
expect(findRegistration(params.pluginId).webSearchProviderIds).toEqual(
params.webSearchProviderIds,
);
});
}
if (params.webFetchProviderIds) {
it("keeps bundled web fetch ownership explicit", () => {
expect(findRegistration(params.pluginId).webFetchProviderIds).toEqual(
params.webFetchProviderIds,
);
});
}
if (params.speechProviderIds) {
it("keeps bundled speech ownership explicit", () => {
expect(findRegistration(params.pluginId).speechProviderIds).toEqual(
params.speechProviderIds,
);
});
}
if (params.realtimeTranscriptionProviderIds) {
it("keeps bundled realtime-transcription ownership explicit", () => {
expect(findRegistration(params.pluginId).realtimeTranscriptionProviderIds).toEqual(
params.realtimeTranscriptionProviderIds,
);
});
}
if (params.realtimeVoiceProviderIds) {
it("keeps bundled realtime-voice ownership explicit", () => {
expect(findRegistration(params.pluginId).realtimeVoiceProviderIds).toEqual(
params.realtimeVoiceProviderIds,
);
});
}
if (params.mediaUnderstandingProviderIds) {
it("keeps bundled media-understanding ownership explicit", () => {
expect(findRegistration(params.pluginId).mediaUnderstandingProviderIds).toEqual(
params.mediaUnderstandingProviderIds,
);
});
}
if (params.imageGenerationProviderIds) {
it("keeps bundled image-generation ownership explicit", () => {
expect(findRegistration(params.pluginId).imageGenerationProviderIds).toEqual(
params.imageGenerationProviderIds,
);
});
}
if (params.videoGenerationProviderIds) {
it("keeps bundled video-generation ownership explicit", () => {
expect(findRegistration(params.pluginId).videoGenerationProviderIds).toEqual(
params.videoGenerationProviderIds,
);
});
}
if (params.musicGenerationProviderIds) {
it("keeps bundled music-generation ownership explicit", () => {
expect(findRegistration(params.pluginId).musicGenerationProviderIds).toEqual(
params.musicGenerationProviderIds,
);
});
}
if (params.toolNames) {
it("keeps bundled tool ownership explicit", () => {
expect(findRegistration(params.pluginId).toolNames).toEqual(params.toolNames);
});
}
const manifestAuthChoice = params.manifestAuthChoice;
if (manifestAuthChoice) {
it("keeps onboarding auth grouping explicit", () => {
const plugin = loadPluginManifestRegistry({}).plugins.find(
(entry) => entry.origin === "bundled" && entry.id === manifestAuthChoice.pluginId,
);
expect(plugin?.providerAuthChoices).toEqual(
expect.arrayContaining([
expect.objectContaining({
choiceId: manifestAuthChoice.choiceId,
choiceLabel: manifestAuthChoice.choiceLabel,
groupId: manifestAuthChoice.groupId,
groupLabel: manifestAuthChoice.groupLabel,
groupHint: manifestAuthChoice.groupHint,
}),
]),
);
});
}
});
}

View file

@ -0,0 +1 @@
export { registerSingleProviderPlugin } from "../../../src/test-utils/plugin-registration.js";

View file

@ -0,0 +1,3 @@
export { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
export { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";

View file

@ -0,0 +1,412 @@
import { vi } from "vitest";
import {
removeAckReactionAfterReply,
shouldAckReaction,
} from "../../../src/channels/ack-reactions.js";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "../../../src/channels/mention-gating.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
const DEFAULT_PROVIDER = "openai";
const DEFAULT_MODEL = "gpt-5.4";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
? T[K]
: T[K] extends ReadonlyArray<unknown>
? T[K]
: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, overrideValue] of Object.entries(overrides as Record<string, unknown>)) {
if (overrideValue === undefined) {
continue;
}
const baseValue = result[key];
if (isObject(baseValue) && isObject(overrideValue)) {
result[key] = mergeDeep(baseValue, overrideValue);
continue;
}
result[key] = overrideValue;
}
return result as T;
}
function createTaskFlowSessionMock() {
return {
sessionKey: "agent:main:main",
createManaged: vi.fn(),
get: vi.fn(),
list: vi.fn(() => []),
findLatest: vi.fn(),
resolve: vi.fn(),
getTaskSummary: vi.fn(),
setWaiting: vi.fn(),
resume: vi.fn(),
finish: vi.fn(),
fail: vi.fn(),
requestCancel: vi.fn(),
cancel: vi.fn(),
runTask: vi.fn(),
};
}
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
const taskFlow = {
bindSession: vi.fn(
createTaskFlowSessionMock,
) as unknown as PluginRuntime["taskFlow"]["bindSession"],
fromToolContext: vi.fn(
createTaskFlowSessionMock,
) as unknown as PluginRuntime["taskFlow"]["fromToolContext"],
};
const base: PluginRuntime = {
version: "1.0.0-test",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
agent: {
defaults: {
model: DEFAULT_MODEL,
provider: DEFAULT_PROVIDER,
},
resolveAgentDir: vi.fn(
() => "/tmp/agent",
) as unknown as PluginRuntime["agent"]["resolveAgentDir"],
resolveAgentWorkspaceDir: vi.fn(
() => "/tmp/workspace",
) as unknown as PluginRuntime["agent"]["resolveAgentWorkspaceDir"],
resolveAgentIdentity: vi.fn(() => ({
name: "test-agent",
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
resolveThinkingDefault: vi.fn(
() => "off",
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
payloads: [],
meta: {},
}) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"],
runEmbeddedAgent: vi.fn().mockResolvedValue({
payloads: [],
meta: {},
}) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"],
resolveAgentTimeoutMs: vi.fn(
() => 30_000,
) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"],
ensureAgentWorkspace: vi
.fn()
.mockResolvedValue(undefined) as unknown as PluginRuntime["agent"]["ensureAgentWorkspace"],
session: {
resolveStorePath: vi.fn(
() => "/tmp/agent-sessions.json",
) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"],
loadSessionStore: vi.fn(
() => ({}),
) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"],
saveSessionStore: vi
.fn()
.mockResolvedValue(
undefined,
) as unknown as PluginRuntime["agent"]["session"]["saveSessionStore"],
resolveSessionFilePath: vi.fn(
(sessionId: string) => `/tmp/${sessionId}.json`,
) as unknown as PluginRuntime["agent"]["session"]["resolveSessionFilePath"],
},
},
system: {
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runHeartbeatOnce: vi.fn(async () => ({
status: "ran" as const,
durationMs: 0,
})) as unknown as PluginRuntime["system"]["runHeartbeatOnce"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeech: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeech"],
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
listVoices: vi.fn() as unknown as PluginRuntime["tts"]["listVoices"],
},
mediaUnderstanding: {
runFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["runFile"],
describeImageFile:
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFile"],
describeImageFileWithModel:
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFileWithModel"],
describeVideoFile:
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeVideoFile"],
transcribeAudioFile:
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"],
},
imageGeneration: {
generate: vi.fn() as unknown as PluginRuntime["imageGeneration"]["generate"],
listProviders: vi.fn() as unknown as PluginRuntime["imageGeneration"]["listProviders"],
},
musicGeneration: {
generate: vi.fn() as unknown as PluginRuntime["musicGeneration"]["generate"],
listProviders: vi.fn() as unknown as PluginRuntime["musicGeneration"]["listProviders"],
},
videoGeneration: {
generate: vi.fn() as unknown as PluginRuntime["videoGeneration"]["generate"],
listProviders: vi.fn() as unknown as PluginRuntime["videoGeneration"]["listProviders"],
},
webSearch: {
listProviders: vi.fn() as unknown as PluginRuntime["webSearch"]["listProviders"],
search: vi.fn() as unknown as PluginRuntime["webSearch"]["search"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
channel: {
text: {
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),
chunkMarkdownText: vi.fn((text: string) => [text]),
chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
chunkText: vi.fn((text: string) => (text ? [text] : [])),
chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
resolveChunkMode: vi.fn(
() => "length",
) as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(() => 4000),
hasControlCommand: vi.fn(() => false),
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text),
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => undefined,
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
}) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions: vi.fn(() => ({
template: "channel+name+time",
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
buildAgentSessionKey: vi.fn(
({
agentId,
channel,
peer,
}: {
agentId: string;
channel: string;
peer?: { kind?: string; id?: string };
}) => `agent:${agentId}:${channel}:${peer?.kind ?? "direct"}:${peer?.id ?? "peer"}`,
) as unknown as PluginRuntime["channel"]["routing"]["buildAgentSessionKey"],
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:test:dm:peer",
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: vi.fn(
() => "Pairing code: TESTCODE",
) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore: vi
.fn()
.mockResolvedValue(
[],
) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest: vi.fn().mockResolvedValue({
code: "TESTCODE",
created: true,
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: vi.fn(
() => "/tmp/sessions.json",
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: vi.fn(
() => undefined,
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes: vi.fn(() => [
/\bbert\b/i,
]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((regex) => regex.test(text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
matchesMentionWithExplicit: vi.fn(
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) =>
params.explicitWasMentioned === true
? true
: params.mentionRegexes.some((regex) => regex.test(params.text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
implicitMentionKindWhen,
resolveInboundMentionDecision,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: vi.fn(
() => "open",
) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
outbound: {
loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"],
},
threadBindings: {
setIdleTimeoutBySessionKey:
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"],
setMaxAgeBySessionKey:
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setMaxAgeBySessionKey"],
},
runtimeContexts: {
register: vi.fn(({ abortSignal }: { abortSignal?: AbortSignal }) => {
const lease = { dispose: vi.fn() };
abortSignal?.addEventListener("abort", lease.dispose, { once: true });
return lease;
}) as unknown as PluginRuntime["channel"]["runtimeContexts"]["register"],
get: vi.fn() as unknown as PluginRuntime["channel"]["runtimeContexts"]["get"],
watch: vi.fn(() =>
vi.fn(),
) as unknown as PluginRuntime["channel"]["runtimeContexts"]["watch"],
},
activity: {} as PluginRuntime["channel"]["activity"],
},
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(() => false),
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
},
state: {
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
},
tasks: {
runs: {
bindSession: vi.fn(),
fromToolContext: vi.fn(),
} as PluginRuntime["tasks"]["runs"],
flows: {
bindSession: vi.fn(),
fromToolContext: vi.fn(),
} as PluginRuntime["tasks"]["flows"],
flow: taskFlow,
},
taskFlow,
modelAuth: {
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
getRuntimeAuthForModel:
vi.fn() as unknown as PluginRuntime["modelAuth"]["getRuntimeAuthForModel"],
resolveApiKeyForProvider:
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
},
subagent: {
run: vi.fn(),
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
deleteSession: vi.fn(),
},
};
return mergeDeep(base, overrides);
}

View file

@ -0,0 +1,65 @@
"use strict";
let stub;
stub = new Proxy(
function pluginSdkStub() {
return stub;
},
{
apply() {
return stub;
},
construct() {
return stub;
},
get(_target, prop) {
if (prop === "__esModule") {
return true;
}
if (prop === "default") {
return stub;
}
if (prop === "then") {
return undefined;
}
if (prop === Symbol.toPrimitive) {
return () => "";
}
if (prop === "toJSON") {
return () => undefined;
}
if (prop === "toString") {
return () => "";
}
if (prop === "valueOf") {
return () => 0;
}
return stub;
},
ownKeys(target) {
return [...new Set([...Reflect.ownKeys(target), "__esModule", "default"])];
},
getOwnPropertyDescriptor(target, prop) {
if (prop === "__esModule") {
return {
configurable: true,
enumerable: false,
value: true,
writable: false,
};
}
if (prop === "default") {
return {
configurable: true,
enumerable: false,
value: stub,
writable: false,
};
}
return Reflect.getOwnPropertyDescriptor(target, prop);
},
},
);
module.exports = stub;

View file

@ -0,0 +1,405 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
import { createNonExitingRuntime } from "../../../src/runtime.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import type {
WizardMultiSelectParams,
WizardPrompter,
WizardProgress,
WizardSelectParams,
} from "../../../src/wizard/prompts.js";
import { registerProviders, requireProvider } from "./contracts-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("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
type EnsureAuthProfileStore =
typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore;
type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/provider-auth").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>());
const providerAuthContractModules = {
githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "github-copilot",
artifactBasename: "index.js",
}),
openAIIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "openai",
artifactBasename: "index.js",
}),
};
vi.mock("openclaw/plugin-sdk/provider-auth-login", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth-login")>(
"openclaw/plugin-sdk/provider-auth-login",
);
return {
...actual,
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
"openclaw/plugin-sdk/provider-auth",
);
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
return (await import(`${moduleUrl}?t=${Date.now()}`)) as T;
}
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 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,
};
}
function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) {
beforeEach(() => {
state.authStore = { version: 1, profiles: {} };
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockImplementation(() => state.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();
});
}
export function describeOpenAICodexProviderAuthContract() {
const state = {
authStore: { version: 1, profiles: {} } as AuthProfileStore,
};
describe("openai-codex provider auth contract", () => {
installSharedAuthProfileStoreHooks(state);
async function expectStableFallbackProfile(params: { access: string; profileId: string }) {
const { default: openAIPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(providerAuthContractModules.openAIIndexModuleUrl);
const provider = requireProvider(await registerProviders(openAIPlugin), "openai-codex");
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,
}),
);
}
async function getProvider() {
const { default: openAIPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(providerAuthContractModules.openAIIndexModuleUrl);
return requireProvider(await registerProviders(openAIPlugin), "openai-codex");
}
it("keeps OAuth auth results provider-owned", async () => {
const provider = await getProvider();
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 OAuth email from the JWT profile claim", async () => {
const provider = await getProvider();
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 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 expectStableFallbackProfile({
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 expectStableFallbackProfile({
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 expectStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("falls back to the default profile when JWT parsing yields no identity", async () => {
const provider = await getProvider();
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 OAuth failures non-fatal at the provider layer", async () => {
const provider = await getProvider();
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
});
});
}
export function describeGithubCopilotProviderAuthContract() {
const state = {
authStore: { version: 1, profiles: {} } as AuthProfileStore,
};
describe("github-copilot provider auth contract", () => {
installSharedAuthProfileStoreHooks(state);
async function getProvider() {
const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(providerAuthContractModules.githubCopilotIndexModuleUrl);
return requireProvider(await registerProviders(githubCopilotPlugin), "github-copilot");
}
it("keeps device auth results provider-owned", async () => {
const provider = await getProvider();
state.authStore.profiles["github-copilot:github"] = {
type: "token",
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/claude-opus-4.6",
});
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
it("keeps auth gated on interactive TTYs", async () => {
const provider = await getProvider();
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

@ -0,0 +1,27 @@
export {
expectAugmentedCodexCatalog,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
} from "../../../src/plugins/provider-runtime.test-support.js";
export type { ProviderPlugin } from "../../../src/plugins/types.js";
export { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type ProviderRuntimeCatalogModule = Pick<
typeof import("../../../src/plugins/provider-runtime.js"),
| "augmentModelCatalogWithProviderPlugins"
| "resetProviderRuntimeHookCacheForTest"
| "resolveProviderBuiltInModelSuppression"
>;
export async function importProviderRuntimeCatalogModule(): Promise<ProviderRuntimeCatalogModule> {
const {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await import("../../../src/plugins/provider-runtime.js");
return {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
};
}

View file

@ -0,0 +1,179 @@
import { expect, it } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
ProviderPlugin,
WebFetchProviderPlugin,
WebSearchProviderPlugin,
} from "../../../src/plugins/types.js";
type Lazy<T> = T | (() => T);
type WebProviderCredentialContract = Pick<
WebSearchProviderPlugin,
| "createTool"
| "docsUrl"
| "envVars"
| "getCredentialValue"
| "hint"
| "id"
| "label"
| "placeholder"
| "setCredentialValue"
| "signupUrl"
>;
function resolveLazy<T>(value: Lazy<T>): T {
return typeof value === "function" ? (value as () => T)() : value;
}
function expectWebProviderCredentialContract(
provider: WebProviderCredentialContract,
credentialValue: unknown,
) {
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
expect(provider.label.trim()).not.toBe("");
expect(provider.hint.trim()).not.toBe("");
expect(provider.placeholder.trim()).not.toBe("");
expect(provider.signupUrl.startsWith("https://")).toBe(true);
if (provider.docsUrl) {
expect(provider.docsUrl.startsWith("http")).toBe(true);
}
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
const configTarget: Record<string, unknown> = {};
provider.setCredentialValue(configTarget, credentialValue);
expect(provider.getCredentialValue(configTarget)).toEqual(credentialValue);
expect(typeof provider.createTool).toBe("function");
return configTarget;
}
export function installProviderPluginContractSuite(params: { provider: Lazy<ProviderPlugin> }) {
it("satisfies the base provider plugin contract", () => {
const provider = resolveLazy(params.provider);
const authIds = provider.auth.map((method) => method.id);
const wizardChoiceIds = new Set<string>();
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
expect(provider.label.trim()).not.toBe("");
if (provider.docsPath) {
expect(provider.docsPath.startsWith("/")).toBe(true);
}
if (provider.aliases) {
expect(provider.aliases).toEqual([...new Set(provider.aliases)]);
}
if (provider.envVars) {
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
}
expect(Array.isArray(provider.auth)).toBe(true);
expect(authIds).toEqual([...new Set(authIds)]);
for (const method of provider.auth) {
expect(method.id.trim()).not.toBe("");
expect(method.label.trim()).not.toBe("");
if (method.hint !== undefined) {
expect(method.hint.trim()).not.toBe("");
}
if (method.wizard) {
if (method.wizard.choiceId) {
expect(method.wizard.choiceId.trim()).not.toBe("");
expect(wizardChoiceIds.has(method.wizard.choiceId)).toBe(false);
wizardChoiceIds.add(method.wizard.choiceId);
}
if (method.wizard.methodId) {
expect(authIds).toContain(method.wizard.methodId);
}
if (method.wizard.modelAllowlist?.allowedKeys) {
expect(method.wizard.modelAllowlist.allowedKeys).toEqual([
...new Set(method.wizard.modelAllowlist.allowedKeys),
]);
}
if (method.wizard.modelAllowlist?.initialSelections) {
expect(method.wizard.modelAllowlist.initialSelections).toEqual([
...new Set(method.wizard.modelAllowlist.initialSelections),
]);
}
}
expect(typeof method.run).toBe("function");
}
if (provider.wizard?.setup || provider.wizard?.modelPicker) {
expect(provider.auth.length).toBeGreaterThan(0);
}
if (provider.wizard?.setup) {
if (provider.wizard.setup.choiceId) {
expect(provider.wizard.setup.choiceId.trim()).not.toBe("");
expect(wizardChoiceIds.has(provider.wizard.setup.choiceId)).toBe(false);
}
if (provider.wizard.setup.methodId) {
expect(authIds).toContain(provider.wizard.setup.methodId);
}
if (provider.wizard.setup.modelAllowlist?.allowedKeys) {
expect(provider.wizard.setup.modelAllowlist.allowedKeys).toEqual([
...new Set(provider.wizard.setup.modelAllowlist.allowedKeys),
]);
}
if (provider.wizard.setup.modelAllowlist?.initialSelections) {
expect(provider.wizard.setup.modelAllowlist.initialSelections).toEqual([
...new Set(provider.wizard.setup.modelAllowlist.initialSelections),
]);
}
}
if (provider.wizard?.modelPicker?.methodId) {
expect(authIds).toContain(provider.wizard.modelPicker.methodId);
}
});
}
export function installWebSearchProviderContractSuite(params: {
provider: Lazy<WebSearchProviderPlugin>;
credentialValue: Lazy<unknown>;
}) {
it("satisfies the base web search provider contract", () => {
const provider = resolveLazy(params.provider);
const credentialValue = resolveLazy(params.credentialValue);
const searchConfigTarget = expectWebProviderCredentialContract(provider, credentialValue);
expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue);
if (provider.runSetup) {
expect(typeof provider.runSetup).toBe("function");
}
});
}
export function installWebFetchProviderContractSuite(params: {
provider: Lazy<WebFetchProviderPlugin>;
credentialValue: Lazy<unknown>;
pluginId?: string;
}) {
it("satisfies the base web fetch provider contract", () => {
const provider = resolveLazy(params.provider);
const credentialValue = resolveLazy(params.credentialValue);
expectWebProviderCredentialContract(provider, credentialValue);
expect(provider.credentialPath.trim()).not.toBe("");
if (provider.inactiveSecretPaths) {
expect(provider.inactiveSecretPaths).toEqual([...new Set(provider.inactiveSecretPaths)]);
expect(provider.inactiveSecretPaths).toContain(provider.credentialPath);
}
const fetchConfigTarget: Record<string, unknown> = {};
provider.setCredentialValue(fetchConfigTarget, credentialValue);
expect(provider.getCredentialValue(fetchConfigTarget)).toEqual(credentialValue);
if (provider.setConfiguredCredentialValue && provider.getConfiguredCredentialValue) {
const configTarget = {} as OpenClawConfig;
provider.setConfiguredCredentialValue(configTarget, credentialValue);
expect(provider.getConfiguredCredentialValue(configTarget)).toEqual(credentialValue);
}
if (provider.applySelectionConfig && params.pluginId) {
const applied = provider.applySelectionConfig({} as OpenClawConfig);
expect(applied.plugins?.entries?.[params.pluginId]?.enabled).toBe(true);
}
});
}

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
pluginRegistrationContractRegistry,
providerContractLoadError,
resolveProviderContractProvidersForPluginIds,
} from "../../../src/plugins/contracts/registry.js";
import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../../../src/plugins/provider-contract-public-artifacts.js";
import type { ProviderPlugin } from "../../../src/plugins/types.js";
import { installProviderPluginContractSuite } from "./provider-contract-suites.js";
type ProviderContractEntry = {
pluginId: string;
provider: ProviderPlugin;
};
function resolveProviderContractProvidersFromPublicArtifact(
pluginId: string,
): ProviderContractEntry[] | null {
return resolveBundledExplicitProviderContractsFromPublicArtifacts({ onlyPluginIds: [pluginId] });
}
export function describeProviderContracts(pluginId: string) {
const providerIds =
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ??
[];
const resolveProviderEntries = (): ProviderContractEntry[] => {
const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId);
if (publicArtifactProviders) {
return publicArtifactProviders;
}
return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({
pluginId,
provider,
}));
};
describe(`${pluginId} provider contract registry load`, () => {
it("loads bundled providers without import-time registry failure", () => {
const providers = resolveProviderEntries();
expect(providerContractLoadError).toBeUndefined();
expect(providers.length).toBeGreaterThan(0);
});
});
for (const providerId of providerIds) {
describe(`${pluginId}:${providerId} provider contract`, () => {
// Resolve provider entries lazily so the non-isolated extension runner
// does not race provider contract collection against other file imports.
installProviderPluginContractSuite({
provider: () => {
const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId);
if (!entry) {
throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`);
}
return entry.provider;
},
});
});
}
}

View file

@ -0,0 +1,660 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
resolveBundledPluginPublicModulePath,
resolveRelativeBundledPluginPublicModuleId,
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { registerProviders, requireProvider } from "./contracts-testkit.js";
const resolveCopilotApiTokenMock = 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());
const bundledProviderModules = {
cloudflareAiGatewayIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "cloudflare-ai-gateway",
artifactBasename: "index.js",
}),
githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "github-copilot",
artifactBasename: "index.js",
}),
githubCopilotRegisterRuntimeModuleId: resolveBundledPluginPublicModulePath({
pluginId: "github-copilot",
artifactBasename: "register.runtime.js",
}),
minimaxIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "minimax",
artifactBasename: "index.js",
}),
qwenIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "qwen",
artifactBasename: "index.js",
}),
sglangApiModuleId: resolveBundledPluginPublicModulePath({
pluginId: "sglang",
artifactBasename: "api.js",
}),
sglangIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "sglang",
artifactBasename: "index.js",
}),
vllmApiModuleId: resolveBundledPluginPublicModulePath({
pluginId: "vllm",
artifactBasename: "api.js",
}),
vllmIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "vllm",
artifactBasename: "index.js",
}),
};
type ProviderHandle = Awaited<ReturnType<typeof requireProvider>>;
type DiscoveryState = {
runProviderCatalog: typeof import("../../../src/plugins/provider-discovery.js").runProviderCatalog;
githubCopilotProvider?: ProviderHandle;
vllmProvider?: ProviderHandle;
sglangProvider?: ProviderHandle;
minimaxProvider?: ProviderHandle;
minimaxPortalProvider?: ProviderHandle;
modelStudioProvider?: ProviderHandle;
cloudflareAiGatewayProvider?: ProviderHandle;
};
type BundledProviderUnderTest =
| "github-copilot"
| "vllm"
| "sglang"
| "minimax"
| "modelstudio"
| "cloudflare-ai-gateway";
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(
state: DiscoveryState,
params: {
provider: ProviderHandle;
config?: OpenClawConfig;
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 state.runProviderCatalog({
provider: params.provider,
config: params.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",
})),
});
}
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
return (await import(moduleUrl)) as T;
}
function installDiscoveryHooks(
state: DiscoveryState,
providerIds: readonly BundledProviderUnderTest[],
) {
beforeAll(async () => {
vi.resetModules();
vi.doMock("openclaw/plugin-sdk/agent-runtime", () => {
return {
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
vi.doMock("openclaw/plugin-sdk/provider-auth", () => {
return {
MINIMAX_OAUTH_MARKER: "minimax-oauth",
applyAuthProfileConfig: (config: OpenClawConfig) => config,
buildApiKeyCredential: (
provider: string,
key: unknown,
metadata?: Record<string, unknown>,
) => ({
type: "api_key",
provider,
...(typeof key === "string" ? { key } : {}),
...(metadata ? { metadata } : {}),
}),
buildOauthProviderAuthResult: vi.fn(),
coerceSecretRef: (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null,
ensureApiKeyFromOptionEnvOrPrompt: vi.fn(),
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
normalizeApiKeyInput: (value: unknown) => (typeof value === "string" ? value.trim() : ""),
normalizeOptionalSecretInput: (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined,
resolveNonEnvSecretRefApiKeyMarker: (source: unknown) =>
typeof source === "string" ? source : "",
upsertAuthProfile: vi.fn(),
validateApiKeyInput: () => undefined,
};
});
vi.doMock(bundledProviderModules.githubCopilotRegisterRuntimeModuleId, async () => {
const actual = await vi.importActual<object>(
bundledProviderModules.githubCopilotRegisterRuntimeModuleId,
);
return {
...actual,
resolveCopilotApiToken: resolveCopilotApiTokenMock,
};
});
vi.doMock(bundledProviderModules.vllmApiModuleId, async () => {
return {
VLLM_DEFAULT_API_KEY_ENV_VAR: "VLLM_API_KEY",
VLLM_DEFAULT_BASE_URL: "http://127.0.0.1:8000/v1",
VLLM_MODEL_PLACEHOLDER: "meta-llama/Meta-Llama-3-8B-Instruct",
VLLM_PROVIDER_LABEL: "vLLM",
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
};
});
vi.doMock(bundledProviderModules.sglangApiModuleId, async () => {
return {
SGLANG_DEFAULT_API_KEY_ENV_VAR: "SGLANG_API_KEY",
SGLANG_DEFAULT_BASE_URL: "http://127.0.0.1:30000/v1",
SGLANG_MODEL_PLACEHOLDER: "Qwen/Qwen3-8B",
SGLANG_PROVIDER_LABEL: "SGLang",
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
({ runProviderCatalog: state.runProviderCatalog } =
await import("../../../src/plugins/provider-discovery.js"));
if (providerIds.includes("github-copilot")) {
const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.githubCopilotIndexModuleUrl);
state.githubCopilotProvider = requireProvider(
await registerProviders(githubCopilotPlugin),
"github-copilot",
);
}
if (providerIds.includes("vllm")) {
const { default: vllmPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.vllmIndexModuleUrl);
state.vllmProvider = requireProvider(await registerProviders(vllmPlugin), "vllm");
}
if (providerIds.includes("sglang")) {
const { default: sglangPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.sglangIndexModuleUrl);
state.sglangProvider = requireProvider(await registerProviders(sglangPlugin), "sglang");
}
if (providerIds.includes("minimax")) {
const { default: minimaxPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.minimaxIndexModuleUrl);
const registeredProviders = await registerProviders(minimaxPlugin);
state.minimaxProvider = requireProvider(registeredProviders, "minimax");
state.minimaxPortalProvider = requireProvider(registeredProviders, "minimax-portal");
}
if (providerIds.includes("modelstudio")) {
const { default: qwenPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.qwenIndexModuleUrl);
state.modelStudioProvider = requireProvider(await registerProviders(qwenPlugin), "qwen");
}
if (providerIds.includes("cloudflare-ai-gateway")) {
const { default: cloudflareAiGatewayPlugin } = await importBundledProviderPlugin<{
default: Parameters<typeof registerProviders>[0];
}>(bundledProviderModules.cloudflareAiGatewayIndexModuleUrl);
state.cloudflareAiGatewayProvider = requireProvider(
await registerProviders(cloudflareAiGatewayPlugin),
"cloudflare-ai-gateway",
);
}
});
beforeEach(() => {
setRuntimeAuthStore();
});
afterEach(() => {
vi.restoreAllMocks();
resolveCopilotApiTokenMock.mockReset();
buildVllmProviderMock.mockReset();
buildSglangProviderMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
setRuntimeAuthStore();
});
}
export function describeGithubCopilotProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("github-copilot provider discovery contract", () => {
installDiscoveryHooks(state, ["github-copilot"]);
it("keeps catalog disabled without env tokens or profiles", async () => {
await expect(
runCatalog(state, { provider: state.githubCopilotProvider! }),
).resolves.toBeNull();
});
it("keeps profile-only catalog fallback provider-owned", async () => {
setGithubCopilotProfileSnapshot();
await expect(
runCatalog(state, {
provider: state.githubCopilotProvider!,
}),
).resolves.toEqual({
provider: {
baseUrl: "https://api.individual.githubcopilot.com",
models: [],
},
});
});
it("keeps 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(state, {
provider: state.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",
}),
});
});
});
}
export function describeVllmProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("vllm provider discovery contract", () => {
installDiscoveryHooks(state, ["vllm"]);
it("keeps 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(
runCatalog(state, {
provider: state.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",
});
});
});
}
export function describeSglangProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("sglang provider discovery contract", () => {
installDiscoveryHooks(state, ["sglang"]);
it("keeps 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(
runCatalog(state, {
provider: state.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",
});
});
});
}
export function describeMinimaxProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("minimax provider discovery contract", () => {
installDiscoveryHooks(state, ["minimax"]);
it("keeps API catalog provider-owned", async () => {
await expect(
state.runProviderCatalog({
provider: state.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 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(
runCatalog(state, {
provider: state.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 portal explicit base URL override provider-owned", async () => {
await expect(
state.runProviderCatalog({
provider: state.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",
},
});
});
});
}
export function describeModelStudioProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("modelstudio provider discovery contract", () => {
installDiscoveryHooks(state, ["modelstudio"]);
it("keeps catalog provider-owned", async () => {
await expect(
state.runProviderCatalog({
provider: state.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: "qwen3-max-2026-01-23" }),
expect.objectContaining({ id: "MiniMax-M2.5" }),
]),
},
});
});
});
}
export function describeCloudflareAiGatewayProviderDiscoveryContract() {
const state = {} as DiscoveryState;
describe("cloudflare-ai-gateway provider discovery contract", () => {
installDiscoveryHooks(state, ["cloudflare-ai-gateway"]);
it("keeps catalog disabled without stored metadata", async () => {
await expect(
runCatalog(state, {
provider: state.cloudflareAiGatewayProvider!,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
});
it("keeps 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(
runCatalog(state, {
provider: state.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

@ -0,0 +1,91 @@
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "openclaw/plugin-sdk/provider-onboard";
import type { ModelApi } from "openclaw/plugin-sdk/provider-onboard";
import { expect } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
createConfigWithFallbacks,
createLegacyProviderConfig,
EXPECTED_FALLBACKS,
} from "./onboard-config.js";
export function expectProviderOnboardAllowlistAlias(params: {
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
modelRef: string;
alias: string;
}) {
const withDefault = params.applyProviderConfig({});
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(params.modelRef);
const withAlias = params.applyProviderConfig({
agents: {
defaults: {
models: {
[params.modelRef]: { alias: params.alias },
},
},
},
});
expect(withAlias.agents?.defaults?.models?.[params.modelRef]?.alias).toBe(params.alias);
}
export function expectProviderOnboardPrimaryAndFallbacks(params: {
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
modelRef: string;
}) {
expectProviderOnboardPrimaryModel(params);
const cfgWithFallbacks = params.applyConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
}
export function expectProviderOnboardPrimaryModel(params: {
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
modelRef: string;
}) {
const cfg = params.applyConfig({});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.modelRef);
}
export function expectProviderOnboardPreservesPrimary(params: {
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
primaryModelRef: string;
}) {
const cfg = params.applyProviderConfig({
agents: { defaults: { model: { primary: params.primaryModelRef } } },
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.primaryModelRef);
}
export function expectProviderOnboardMergedLegacyConfig(params: {
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
providerId: string;
providerApi: ModelApi;
baseUrl: string;
legacyApi: Parameters<typeof createLegacyProviderConfig>[0]["api"];
legacyModelId?: string;
legacyModelName?: string;
legacyBaseUrl?: string;
legacyApiKey?: string;
}) {
const cfg = params.applyProviderConfig(
createLegacyProviderConfig({
providerId: params.providerId,
api: params.legacyApi,
modelId: params.legacyModelId,
modelName: params.legacyModelName,
baseUrl: params.legacyBaseUrl,
apiKey: params.legacyApiKey,
}),
);
const provider = cfg.models?.providers?.[params.providerId];
expect(provider?.baseUrl).toBe(params.baseUrl);
expect(provider?.api).toBe(params.providerApi);
expect(provider?.apiKey).toBe((params.legacyApiKey ?? "old-key").trim());
return provider;
}

View file

@ -0,0 +1,84 @@
import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
MusicGenerationProviderPlugin,
ProviderPlugin,
SpeechProviderPlugin,
VideoGenerationProviderPlugin,
} from "../../../src/plugins/types.js";
import { createTestPluginApi } from "./plugin-api.js";
type RegisteredProviderCollections = {
providers: ProviderPlugin[];
speechProviders: SpeechProviderPlugin[];
mediaProviders: MediaUnderstandingProviderPlugin[];
imageProviders: ImageGenerationProviderPlugin[];
musicProviders: MusicGenerationProviderPlugin[];
videoProviders: VideoGenerationProviderPlugin[];
};
type ProviderPluginModule = {
register(api: ReturnType<typeof createTestPluginApi>): void;
};
export async function registerProviderPlugin(params: {
plugin: ProviderPluginModule;
id: string;
name: string;
}): Promise<RegisteredProviderCollections> {
const providers: ProviderPlugin[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const mediaProviders: MediaUnderstandingProviderPlugin[] = [];
const imageProviders: ImageGenerationProviderPlugin[] = [];
const musicProviders: MusicGenerationProviderPlugin[] = [];
const videoProviders: VideoGenerationProviderPlugin[] = [];
params.plugin.register(
createTestPluginApi({
id: params.id,
name: params.name,
source: "test",
config: {},
runtime: {} as never,
registerProvider: (provider) => {
providers.push(provider);
},
registerSpeechProvider: (provider) => {
speechProviders.push(provider);
},
registerMediaUnderstandingProvider: (provider) => {
mediaProviders.push(provider);
},
registerImageGenerationProvider: (provider) => {
imageProviders.push(provider);
},
registerMusicGenerationProvider: (provider) => {
musicProviders.push(provider);
},
registerVideoGenerationProvider: (provider) => {
videoProviders.push(provider);
},
}),
);
return {
providers,
speechProviders,
mediaProviders,
imageProviders,
musicProviders,
videoProviders,
};
}
export function requireRegisteredProvider<T extends { id: string }>(
entries: T[],
id: string,
label = "provider",
): T {
const entry = entries.find((candidate) => candidate.id === id);
if (!entry) {
throw new Error(`${label} ${id} was not registered`);
}
return entry;
}

View file

@ -0,0 +1,876 @@
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 type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js";
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../../src/test-utils/provider-usage-fetch.js";
import { registerProviderPlugin, requireRegisteredProvider } from "./provider-registration.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" },
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" },
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" },
]),
);
const providerRuntimeContractModules = {
anthropicIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "anthropic",
artifactBasename: "index.js",
}),
githubCopilotIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "github-copilot",
artifactBasename: "index.js",
}),
googleIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "google",
artifactBasename: "index.js",
}),
openAIIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "openai",
artifactBasename: "index.js",
}),
openRouterIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "openrouter",
artifactBasename: "index.js",
}),
veniceIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "venice",
artifactBasename: "index.js",
}),
zaiIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "zai",
artifactBasename: "index.js",
}),
};
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,
};
});
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
return (await import(moduleUrl)) as T;
}
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 importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.anthropicIndexModuleId),
},
{
providerIds: ["github-copilot"],
pluginId: "github-copilot",
name: "GitHub Copilot",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.githubCopilotIndexModuleId),
},
{
providerIds: ["google", "google-gemini-cli"],
pluginId: "google",
name: "Google",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.googleIndexModuleId),
},
{
providerIds: ["openai", "openai-codex"],
pluginId: "openai",
name: "OpenAI",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.openAIIndexModuleId),
},
{
providerIds: ["openrouter"],
pluginId: "openrouter",
name: "OpenRouter",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.openRouterIndexModuleId),
},
{
providerIds: ["venice"],
pluginId: "venice",
name: "Venice",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.veniceIndexModuleId),
},
{
providerIds: ["zai"],
pluginId: "zai",
name: "Z.AI",
load: async () =>
await importBundledProviderPlugin<{
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
}>(providerRuntimeContractModules.zaiIndexModuleId),
},
] as const;
const providerRuntimeContractProviders = new Map<string, ProviderPlugin>();
let providerRuntimeContractLoadPromise: Promise<void> | null = null;
function requireProviderContractProvider(providerId: string): ProviderPlugin {
const provider = providerRuntimeContractProviders.get(providerId);
if (!provider) {
throw new Error(`provider runtime contract fixture missing for ${providerId}`);
}
return provider;
}
async function ensureProviderRuntimeContractProvidersLoaded() {
if (!providerRuntimeContractLoadPromise) {
providerRuntimeContractLoadPromise = (async () => {
providerRuntimeContractProviders.clear();
const registeredFixtures = await Promise.all(
PROVIDER_RUNTIME_CONTRACT_FIXTURES.map(async (fixture) => {
const plugin = await fixture.load();
return {
fixture,
providers: (
await 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"),
);
}
}
})();
}
await providerRuntimeContractLoadPromise;
}
function installRuntimeHooks() {
beforeAll(async () => {
await ensureProviderRuntimeContractProvidersLoaded();
}, CONTRACT_SETUP_TIMEOUT_MS);
beforeEach(() => {
refreshOpenAICodexTokenMock.mockReset();
getOAuthProvidersMock.mockClear();
}, CONTRACT_SETUP_TIMEOUT_MS);
}
export function describeAnthropicProviderRuntimeContract() {
describe("anthropic provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
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,
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",
});
});
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") },
],
});
});
});
}
export function describeGithubCopilotProviderRuntimeContract() {
describe(
"github-copilot provider runtime contract",
{ timeout: CONTRACT_SETUP_TIMEOUT_MS },
() => {
installRuntimeHooks();
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",
});
});
},
);
}
export function describeGoogleProviderRuntimeContract() {
describe("google provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
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,
});
});
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);
});
});
}
export function describeOpenAIProviderRuntimeContract() {
describe("openai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
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",
});
});
it("owns refresh fallback for accountId extraction failures", 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 forward-compat codex mini models", () => {
const provider = requireProviderContractProvider("openai-codex");
const model = provider.resolveDynamicModel?.({
provider: "openai-codex",
modelId: "gpt-5.4-mini",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gpt-5.1-codex-mini"
? 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-mini",
provider: "openai-codex",
api: "openai-codex-responses",
contextWindow: 272_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",
});
});
});
}
export function describeOpenRouterProviderRuntimeContract() {
describe("openrouter provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
it("owns dynamic OpenRouter model defaults", () => {
const provider = requireProviderContractProvider("openrouter");
const model = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: "x-ai/grok-4-1-fast",
modelRegistry: {
find: () => null,
} as never,
});
expect(model).toMatchObject({
id: "x-ai/grok-4-1-fast",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
maxTokens: 8192,
});
});
});
}
export function describeVeniceProviderRuntimeContract() {
describe("venice provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
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",
},
});
});
});
}
export function describeZAIProviderRuntimeContract() {
describe("zai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
installRuntimeHooks();
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

@ -0,0 +1,4 @@
export {
createProviderUsageFetch,
makeResponse,
} from "../../../src/test-utils/provider-usage-fetch.js";

View file

@ -0,0 +1,239 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildProviderPluginMethodChoice,
resolveProviderModelPickerEntries,
resolveProviderPluginChoice,
resolveProviderWizardOptions,
} from "../../../src/plugins/provider-wizard.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../../../src/plugins/types.js";
const resolvePluginProvidersMock = vi.fn();
vi.mock("../../../src/plugins/providers.runtime.js", () => ({
isPluginProvidersLoadInFlight: () => false,
resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args),
}));
function createAuthMethod(
params: Pick<ProviderAuthMethod, "id" | "label"> &
Partial<Pick<ProviderAuthMethod, "hint" | "wizard">>,
): ProviderAuthMethod {
return {
id: params.id,
label: params.label,
...(params.hint ? { hint: params.hint } : {}),
...(params.wizard ? { wizard: params.wizard } : {}),
kind: "api_key",
run: async () => ({ profiles: [] }),
};
}
const TEST_PROVIDERS: ProviderPlugin[] = [
{
id: "alpha",
label: "Alpha",
auth: [
createAuthMethod({
id: "api-key",
label: "API key",
wizard: {
choiceLabel: "Alpha key",
choiceHint: "Use an API key",
groupId: "alpha",
groupLabel: "Alpha",
onboardingScopes: ["text-inference"],
},
}),
createAuthMethod({
id: "oauth",
label: "OAuth",
wizard: {
choiceId: "alpha-oauth",
choiceLabel: "Alpha OAuth",
groupId: "alpha",
groupLabel: "Alpha",
groupHint: "Recommended",
},
}),
],
wizard: {
modelPicker: {
label: "Alpha custom",
hint: "Pick Alpha models",
methodId: "oauth",
},
},
},
{
id: "beta",
label: "Beta",
auth: [createAuthMethod({ id: "token", label: "Token" })],
wizard: {
setup: {
choiceLabel: "Beta setup",
groupId: "beta",
groupLabel: "Beta",
},
modelPicker: {
label: "Beta custom",
},
},
},
{
id: "gamma",
label: "Gamma",
auth: [
createAuthMethod({ id: "default", label: "Default auth" }),
createAuthMethod({ id: "alt", label: "Alt auth" }),
],
wizard: {
setup: {
methodId: "alt",
choiceId: "gamma-alt",
choiceLabel: "Gamma alt",
groupId: "gamma",
groupLabel: "Gamma",
},
},
},
];
const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted((left, right) =>
left.localeCompare(right),
);
function sortedValues(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function expectUniqueValues(values: readonly string[]) {
expect(values).toEqual([...new Set(values)]);
}
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
return sortedValues(
providers.flatMap((provider) => {
const methodSetups = provider.auth.filter((method) => method.wizard);
if (methodSetups.length > 0) {
return methodSetups.map(
(method) =>
method.wizard?.choiceId?.trim() ||
buildProviderPluginMethodChoice(provider.id, method.id),
);
}
const setup = provider.wizard?.setup;
if (!setup) {
return [];
}
const explicitMethodId = setup.methodId?.trim();
if (explicitMethodId && provider.auth.some((method) => method.id === explicitMethodId)) {
return [
setup.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, explicitMethodId),
];
}
if (provider.auth.length === 1) {
return [setup.choiceId?.trim() || provider.id];
}
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
}),
);
}
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
return sortedValues(
providers.flatMap((provider) => {
const modelPicker = provider.wizard?.modelPicker;
if (!modelPicker) {
return [];
}
const explicitMethodId = modelPicker.methodId?.trim();
if (explicitMethodId) {
return [buildProviderPluginMethodChoice(provider.id, explicitMethodId)];
}
if (provider.auth.length === 1) {
return [provider.id];
}
return [buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default")];
}),
);
}
function expectAllChoicesResolve(
values: readonly string[],
resolver: (choice: string) => ReturnType<typeof resolveProviderPluginChoice>,
) {
expect(
values.every((value) => Boolean(resolver(value))),
values.join(", "),
).toBe(true);
}
beforeEach(() => {
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue(TEST_PROVIDERS);
});
export function describeProviderWizardSetupOptionsContract() {
describe("provider wizard setup options contract", () => {
it("exposes every wizard setup choice through the shared wizard layer", () => {
const options = resolveProviderWizardOptions({
config: {
plugins: {
enabled: true,
allow: TEST_PROVIDER_IDS,
slots: {
memory: "none",
},
},
},
env: process.env,
});
expect(sortedValues(options.map((option) => option.value))).toEqual(
resolveExpectedWizardChoiceValues(TEST_PROVIDERS),
);
expectUniqueValues(options.map((option) => option.value));
});
});
}
export function describeProviderWizardChoiceResolutionContract() {
describe("provider wizard choice resolution contract", () => {
it("round-trips every shared wizard choice back to its provider and auth method", () => {
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
expectAllChoicesResolve(
options.map((option) => option.value),
(choice) =>
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice,
}),
);
});
});
}
export function describeProviderWizardModelPickerContract() {
describe("provider wizard model picker contract", () => {
it("exposes every model-picker entry through the shared wizard layer", () => {
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
expect(sortedValues(entries.map((entry) => entry.value))).toEqual(
resolveExpectedModelPickerValues(TEST_PROVIDERS),
);
expectAllChoicesResolve(
entries.map((entry) => entry.value),
(choice) =>
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice,
}),
);
});
});
}

View file

@ -0,0 +1,53 @@
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../../src/plugins/runtime-sidecar-paths.js";
function assertUniqueValues<T extends string>(values: readonly T[], label: string): readonly T[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
continue;
}
seen.add(value);
}
if (duplicates.size > 0) {
throw new Error(`Duplicate ${label}: ${Array.from(duplicates).join(", ")}`);
}
return values;
}
export function getPublicArtifactBasename(relativePath: string): string {
return relativePath.split("/").at(-1) ?? relativePath;
}
const EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
[
"action-runtime.runtime.js",
"action-runtime-api.js",
"allow-from.js",
"api.js",
"auth-presence.js",
"channel-config-api.js",
"index.js",
"login-qr-api.js",
"onboard.js",
"openai-codex-catalog.js",
"provider-catalog.js",
"session-key-api.js",
"setup-api.js",
"setup-entry.js",
"timeouts.js",
"x-search.js",
] as const,
"extra guarded extension public surface basename",
);
export const BUNDLED_RUNTIME_SIDECAR_BASENAMES = assertUniqueValues(
[...new Set(BUNDLED_RUNTIME_SIDECAR_PATHS.map(getPublicArtifactBasename))],
"bundled runtime sidecar basename",
);
export const GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
[...BUNDLED_RUNTIME_SIDECAR_BASENAMES, ...EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES],
"guarded extension public surface basename",
);

View file

@ -0,0 +1,31 @@
import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { vi } from "vitest";
export function createRuntimeEnv(options?: { throwOnExit?: boolean }): OutputRuntimeEnv {
const throwOnExit = options?.throwOnExit ?? true;
return {
log: vi.fn(),
error: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
exit: throwOnExit
? vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
})
: vi.fn(),
};
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets plugin suites ascribe runtime extension shape.
export function createTypedRuntimeEnv<TRuntime>(options?: { throwOnExit?: boolean }): TRuntime {
return createRuntimeEnv(options) as TRuntime;
}
export function createNonExitingRuntimeEnv(): OutputRuntimeEnv {
return createRuntimeEnv({ throwOnExit: false });
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets plugin suites ascribe runtime extension shape.
export function createNonExitingTypedRuntimeEnv<TRuntime>(): TRuntime {
return createTypedRuntimeEnv<TRuntime>({ throwOnExit: false });
}

View file

@ -0,0 +1 @@
export { createRuntimeTaskFlow } from "../../../src/plugins/runtime/runtime-taskflow.js";

View file

@ -0,0 +1,65 @@
import { expect } from "vitest";
type MockFn = (...args: never[]) => unknown;
type CfgThreadingAssertion<TCfg> = {
loadConfig: MockFn;
resolveAccount: MockFn;
cfg: TCfg;
accountId?: string;
};
type SendRuntimeState = {
loadConfig: MockFn;
resolveMarkdownTableMode: MockFn;
convertMarkdownTables: MockFn;
record: MockFn;
};
export function expectProvidedCfgSkipsRuntimeLoad<TCfg>({
loadConfig,
resolveAccount,
cfg,
accountId,
}: CfgThreadingAssertion<TCfg>): void {
expect(loadConfig).not.toHaveBeenCalled();
expect(resolveAccount).toHaveBeenCalledWith({
cfg,
accountId,
});
}
export function expectRuntimeCfgFallback<TCfg>({
loadConfig,
resolveAccount,
cfg,
accountId,
}: CfgThreadingAssertion<TCfg>): void {
expect(loadConfig).toHaveBeenCalledTimes(1);
expect(resolveAccount).toHaveBeenCalledWith({
cfg,
accountId,
});
}
export function createSendCfgThreadingRuntime({
loadConfig,
resolveMarkdownTableMode,
convertMarkdownTables,
record,
}: SendRuntimeState) {
return {
config: {
loadConfig,
},
channel: {
text: {
resolveMarkdownTableMode,
convertMarkdownTables,
},
activity: {
record,
},
},
};
}

Some files were not shown because too many files have changed in this diff Show more