test: move extension-owned coverage into plugins

This commit is contained in:
Peter Steinberger
2026-03-27 15:11:17 +00:00
parent 97297049e7
commit 8ddeada97d
62 changed files with 1871 additions and 1792 deletions

View File

@@ -1,5 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { parseFeishuConversationId } from "../../extensions/feishu/api.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -126,9 +125,75 @@ function isSupportedFeishuDirectConversationId(conversationId: string): boolean
return true;
}
function parseFeishuConversationIdForTest(params: {
conversationId: string;
parentConversationId?: string;
}): {
canonicalConversationId: string;
chatId: string;
topicId?: string;
senderOpenId?: string;
scope: "group" | "group_sender" | "group_topic" | "group_topic_sender";
} | null {
const conversationId = params.conversationId.trim();
const parentConversationId = params.parentConversationId?.trim() || undefined;
if (!conversationId) {
return null;
}
const topicSenderMatch = /^(.+):topic:([^:]+):sender:([^:]+)$/.exec(conversationId);
if (topicSenderMatch) {
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
return {
canonicalConversationId: `${chatId}:topic:${topicId}:sender:${senderOpenId}`,
chatId,
topicId,
senderOpenId,
scope: "group_topic_sender",
};
}
const topicMatch = /^(.+):topic:([^:]+)$/.exec(conversationId);
if (topicMatch) {
const [, chatId, topicId] = topicMatch;
return {
canonicalConversationId: `${chatId}:topic:${topicId}`,
chatId,
topicId,
scope: "group_topic",
};
}
const senderMatch = /^(.+):sender:([^:]+)$/.exec(conversationId);
if (senderMatch) {
const [, chatId, senderOpenId] = senderMatch;
return {
canonicalConversationId: `${chatId}:sender:${senderOpenId}`,
chatId,
senderOpenId,
scope: "group_sender",
};
}
if (parentConversationId) {
return {
canonicalConversationId: `${parentConversationId}:topic:${conversationId}`,
chatId: parentConversationId,
topicId: conversationId,
scope: "group_topic",
};
}
return {
canonicalConversationId: conversationId,
chatId: conversationId,
scope: "group",
};
}
const feishuBindings: ChannelConfiguredBindingProvider = {
compileConfiguredBinding: ({ conversationId }) => {
const parsed = parseFeishuConversationId({ conversationId });
const parsed = parseFeishuConversationIdForTest({ conversationId });
if (
!parsed ||
(parsed.scope !== "group_topic" &&
@@ -146,7 +211,7 @@ const feishuBindings: ChannelConfiguredBindingProvider = {
};
},
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
const incoming = parseFeishuConversationId({
const incoming = parseFeishuConversationIdForTest({
conversationId,
parentConversationId,
});

View File

@@ -1,30 +1,121 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "../../extensions/anthropic/cli-backend.js";
import { buildGoogleGeminiCliBackend } from "../../extensions/google/cli-backend.js";
import { buildOpenAICodexCliBackend } from "../../extensions/openai/cli-backend.js";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
function createBackendEntry(params: {
pluginId: string;
id: string;
config: CliBackendConfig;
bundleMcp?: boolean;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
}) {
return {
pluginId: params.pluginId,
source: "test",
backend: {
id: params.id,
config: params.config,
...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}),
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
},
};
}
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
{
createBackendEntry({
pluginId: "anthropic",
backend: buildAnthropicCliBackend(),
source: "test",
},
{
id: "claude-cli",
config: {
command: "claude",
args: ["stream-json", "--verbose", "--permission-mode", "bypassPermissions"],
resumeArgs: [
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
},
normalizeConfig: (config) => {
const normalizeArgs = (args: string[] | undefined) => {
if (!args) {
return args;
}
const next = args.filter((arg) => arg !== "--dangerously-skip-permissions");
const hasPermissionMode = next.some(
(arg, index) =>
arg === "--permission-mode" || next[index - 1]?.startsWith("--permission-mode="),
);
return hasPermissionMode ? next : [...next, "--permission-mode", "bypassPermissions"];
};
return {
...config,
args: normalizeArgs(config.args),
resumeArgs: normalizeArgs(config.resumeArgs),
};
},
}),
createBackendEntry({
pluginId: "openai",
backend: buildOpenAICodexCliBackend(),
source: "test",
},
{
id: "codex-cli",
config: {
command: "codex",
args: [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
reliability: {
watchdog: {
fresh: {
noOutputTimeoutRatio: 0.8,
minMs: 60_000,
maxMs: 180_000,
},
resume: {
noOutputTimeoutRatio: 0.3,
minMs: 60_000,
maxMs: 180_000,
},
},
},
},
}),
createBackendEntry({
pluginId: "google",
backend: buildGoogleGeminiCliBackend(),
source: "test",
},
id: "google-gemini-cli",
bundleMcp: false,
config: {
command: "gemini",
args: ["--prompt", "--output-format", "json"],
resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"],
modelArg: "--model",
sessionMode: "existing",
sessionIdFields: ["session_id", "sessionId"],
modelAliases: { pro: "gemini-3.1-pro-preview" },
},
}),
];
setActivePluginRegistry(registry);
});

View File

@@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
enrichOllamaModelsWithContext,
resolveOllamaApiBase,
type OllamaTagModel,
} from "../../extensions/ollama/api.js";
import { jsonResponse, requestBodyText, requestUrl } from "../test-helpers/http.js";
describe("ollama-models", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("strips /v1 when resolving the Ollama API base", () => {
expect(resolveOllamaApiBase("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
});
it("enriches discovered models with context windows from /api/show", async () => {
const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "llama3:8b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched).toEqual([
{ name: "llama3:8b", contextWindow: 65536 },
{ name: "deepseek-r1:14b", contextWindow: undefined },
]);
});
});

View File

@@ -3,27 +3,34 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { isTruthyEnvValue } from "../infra/env.js";
const RAW_STREAM_ENABLED = isTruthyEnvValue(process.env.OPENCLAW_RAW_STREAM);
const RAW_STREAM_PATH =
process.env.OPENCLAW_RAW_STREAM_PATH?.trim() ||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl");
let rawStreamReady = false;
function isRawStreamEnabled(): boolean {
return isTruthyEnvValue(process.env.OPENCLAW_RAW_STREAM);
}
function resolveRawStreamPath(): string {
return (
process.env.OPENCLAW_RAW_STREAM_PATH?.trim() ||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl")
);
}
export function appendRawStream(payload: Record<string, unknown>) {
if (!RAW_STREAM_ENABLED) {
if (!isRawStreamEnabled()) {
return;
}
const rawStreamPath = resolveRawStreamPath();
if (!rawStreamReady) {
rawStreamReady = true;
try {
fs.mkdirSync(path.dirname(RAW_STREAM_PATH), { recursive: true });
fs.mkdirSync(path.dirname(rawStreamPath), { recursive: true });
} catch {
// ignore raw stream mkdir failures
}
}
try {
void fs.promises.appendFile(RAW_STREAM_PATH, `${JSON.stringify(payload)}\n`);
void fs.promises.appendFile(rawStreamPath, `${JSON.stringify(payload)}\n`);
} catch {
// ignore raw stream write failures
}

View File

@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it } from "vitest";
import { normalizeTelegramMessagingTarget } from "../../extensions/telegram/api.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js";
function normalizeTelegramMessagingTargetForTest(raw: string): string | undefined {
const trimmed = raw.trim();
return trimmed ? `telegram:${trimmed}` : undefined;
}
describe("extractMessagingToolSend", () => {
beforeEach(() => {
setActivePluginRegistry(
@@ -12,7 +16,7 @@ describe("extractMessagingToolSend", () => {
pluginId: "telegram",
plugin: {
...createChannelTestPluginBase({ id: "telegram" }),
messaging: { normalizeTarget: normalizeTelegramMessagingTarget },
messaging: { normalizeTarget: normalizeTelegramMessagingTargetForTest },
},
source: "test",
},

View File

@@ -1,145 +1,11 @@
import { describe, expect, it } from "vitest";
import { __testing as braveTesting } from "../../../extensions/brave/test-api.js";
import { __testing as moonshotTesting } from "../../../extensions/moonshot/test-api.js";
import { __testing as perplexityTesting } from "../../../extensions/perplexity/web-search-provider.js";
import { __testing as xaiTesting } from "../../../extensions/xai/test-api.js";
import {
buildUnsupportedSearchFilterResponse,
mergeScopedSearchConfig,
} from "../../plugin-sdk/provider-web-search.js";
import { withEnv } from "../../test-utils/env.js";
const {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeToIsoDate,
isoToPerplexityDate,
} = perplexityTesting;
const {
normalizeBraveLanguageParams,
normalizeToIsoDate,
normalizeFreshness,
resolveBraveMode,
mapBraveLlmContextResults,
} = braveTesting;
const { resolveGrokApiKey, resolveGrokModel, resolveGrokInlineCitations, extractGrokContent } =
xaiTesting;
const { resolveKimiApiKey, resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations } =
moonshotTesting;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");
const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-");
const directPerplexityApiKey = ["pplx", "test"].join("-");
const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-");
describe("web_search perplexity compatibility routing", () => {
it("detects API key prefixes", () => {
expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct");
expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter");
expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined();
});
it("prefers explicit baseUrl over key-based defaults", () => {
expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe(
"https://example.com",
);
});
it("resolves OpenRouter env auth and transport", () => {
withEnv(
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey },
() => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: openRouterPerplexityApiKey,
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
},
);
});
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
withEnv(
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
() => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
},
);
});
it("switches direct Perplexity to chat completions when model override is configured", () => {
expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
"perplexity/sonar-reasoning-pro",
);
expect(
resolvePerplexityTransport({
apiKey: directPerplexityApiKey,
model: "perplexity/sonar-reasoning-pro",
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-reasoning-pro",
transport: "chat_completions",
});
});
it("treats unrecognized configured keys as direct Perplexity by default", () => {
expect(
resolvePerplexityTransport({
apiKey: enterprisePerplexityApiKey,
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
transport: "search_api",
});
});
it("normalizes direct Perplexity models for chat completions", () => {
expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true);
expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false);
expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe(
"sonar-pro",
);
expect(
resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"),
).toBe("perplexity/sonar-pro");
});
});
describe("web_search brave language param normalization", () => {
it("normalizes and auto-corrects swapped Brave language params", () => {
expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
search_lang: "tr",
ui_lang: "tr-TR",
});
expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
search_lang: "en",
ui_lang: "en-US",
});
});
it("flags invalid Brave language formats", () => {
expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
});
} from "./web-search-provider-common.js";
import { mergeScopedSearchConfig } from "./web-search-provider-config.js";
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values and maps for Perplexity", () => {
@@ -259,126 +125,3 @@ describe("web_search scoped config merge", () => {
});
});
});
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ [kimiApiKeyEnv]: "kimi-env-key" }, () => {
expect(resolveKimiApiKey({})).toBe("kimi-env-key");
});
});
it("uses config model when provided", () => {
expect(resolveKimiModel({ model: "moonshot-v1-32k" })).toBe("moonshot-v1-32k");
});
it("falls back to default model", () => {
expect(resolveKimiModel({})).toBe("moonshot-v1-128k");
});
it("uses config baseUrl when provided", () => {
expect(resolveKimiBaseUrl({ baseUrl: "https://kimi.example/v1" })).toBe(
"https://kimi.example/v1",
);
});
it("falls back to default baseUrl", () => {
expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1");
});
it("extracts citations from search_results", () => {
expect(
extractKimiCitations({
search_results: [{ url: "https://example.com/one" }, { url: "https://example.com/two" }],
}),
).toEqual(["https://example.com/one", "https://example.com/two"]);
});
});
describe("web_search brave mode resolution", () => {
it("defaults to web mode", () => {
expect(resolveBraveMode({})).toBe("web");
});
it("honors explicit llm-context mode", () => {
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("maps llm context results", () => {
expect(
mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "Example", snippets: ["A", "B"] }],
},
sources: [{ url: "https://example.com", hostname: "example.com", date: "2024-01-01" }],
}),
).toEqual([
{
title: "Example",
url: "https://example.com",
siteName: "example.com",
snippets: ["A", "B"],
},
]);
});
});
describe("web_search grok config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ XAI_API_KEY: "xai-env-key" }, () => {
expect(resolveGrokApiKey({})).toBe("xai-env-key");
});
});
it("uses config model when provided", () => {
expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast");
});
it("normalizes deprecated grok 4.20 beta ids to GA ids", () => {
expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" })).toBe(
"grok-4.20-beta-latest-reasoning",
);
expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" })).toBe(
"grok-4.20-beta-latest-non-reasoning",
);
});
it("falls back to default model", () => {
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
});
it("resolves inline citations flag", () => {
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
expect(resolveGrokInlineCitations({})).toBe(false);
});
it("extracts content and annotation citations", () => {
expect(
extractGrokContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Result",
annotations: [{ type: "url_citation", url: "https://example.com" }],
},
],
},
],
}),
).toEqual({
text: "Result",
annotationCitations: ["https://example.com"],
});
});
});

View File

@@ -2,8 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveDiscordGroupRequireMention } from "../../extensions/discord/api.js";
import { resolveSlackGroupRequireMention } from "../../extensions/slack/api.js";
import type { OpenClawConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
@@ -876,7 +874,7 @@ describe("resolveGroupRequireMention", () => {
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
});
it("matches the Slack plugin resolver for default-account wildcard fallbacks", async () => {
it("keeps core reply-stage resolution aligned for Slack default-account wildcard fallbacks", async () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
@@ -904,13 +902,7 @@ describe("resolveGroupRequireMention", () => {
chatType: "group",
};
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(
resolveSlackGroupRequireMention({
cfg,
groupId: groupResolution.id,
groupChannel: ctx.GroupSubject,
}),
);
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
});
it("uses Discord fallback resolver semantics for guild slug matches", async () => {
@@ -943,7 +935,7 @@ describe("resolveGroupRequireMention", () => {
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
});
it("matches the Discord plugin resolver for slug + wildcard guild fallbacks", async () => {
it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
@@ -972,14 +964,7 @@ describe("resolveGroupRequireMention", () => {
chatType: "group",
};
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(
resolveDiscordGroupRequireMention({
cfg,
groupId: groupResolution.id,
groupChannel: ctx.GroupChannel,
groupSpace: ctx.GroupSpace,
}),
);
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
});
it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => {

View File

@@ -1,12 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
__testing as feishuThreadBindingTesting,
createFeishuThreadBindingManager,
} from "../../../../extensions/feishu/api.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
__testing as sessionBindingTesting,
getSessionBindingService,
registerSessionBindingAdapter,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
import {
@@ -20,9 +18,39 @@ const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
function registerFeishuBindingAdapterForTest(accountId: string) {
const bindings: SessionBindingRecord[] = [];
registerSessionBindingAdapter({
channel: "feishu",
accountId,
capabilities: { placements: ["current"] },
bind: async (input) => {
const record: SessionBindingRecord = {
bindingId: `${input.conversation.channel}:${input.conversation.accountId}:${input.conversation.conversationId}`,
targetSessionKey: input.targetSessionKey,
targetKind: input.targetKind,
conversation: input.conversation,
status: "active",
boundAt: Date.now(),
...(input.metadata ? { metadata: input.metadata } : {}),
};
bindings.push(record);
return record;
},
listBySession: (targetSessionKey) =>
bindings.filter((binding) => binding.targetSessionKey === targetSessionKey),
resolveByConversation: (ref) =>
bindings.find(
(binding) =>
binding.conversation.channel === ref.channel &&
binding.conversation.accountId === ref.accountId &&
binding.conversation.conversationId === ref.conversationId,
) ?? null,
});
}
describe("commands-acp context", () => {
beforeEach(() => {
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
sessionBindingTesting.resetSessionBindingAdaptersForTests();
});
@@ -233,7 +261,7 @@ describe("commands-acp context", () => {
});
it("preserves sender-scoped Feishu topic ids after ACP takeover from the live binding record", async () => {
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "work" });
registerFeishuBindingAdapterForTest("work");
await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:binding:feishu:work:abc123",
targetKind: "session",

View File

@@ -1,75 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
discordOutbound,
imessageOutbound,
signalOutbound,
slackOutbound,
telegramOutbound,
whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type {
ChannelMessagingAdapter,
ChannelOutboundAdapter,
ChannelPlugin,
ChannelThreadingAdapter,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
const mocks = vi.hoisted(() => ({
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
sendMessageMSTeams: vi.fn(async (_params: unknown) => ({
messageId: "m1",
conversationId: "c1",
})),
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({
messageId: "m1",
channelId: "c1",
})),
deliverOutboundPayloads: vi.fn(),
}));
vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../extensions/discord/src/send.js")>();
return {
...actual,
sendMessageDiscord: mocks.sendMessageDiscord,
sendPollDiscord: mocks.sendMessageDiscord,
sendWebhookMessageDiscord: vi.fn(),
};
});
vi.mock("../../../extensions/imessage/src/send.js", () => ({
sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../../extensions/signal/src/send.js", () => ({
sendMessageSignal: mocks.sendMessageSignal,
}));
vi.mock("../../../extensions/slack/src/send.js", () => ({
sendMessageSlack: mocks.sendMessageSlack,
}));
vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../extensions/telegram/src/send.js")>();
return {
...actual,
sendMessageTelegram: mocks.sendMessageTelegram,
};
});
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
sendPollWhatsApp: mocks.sendMessageWhatsApp,
}));
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
sendMessageMattermost: mocks.sendMessageMattermost,
}));
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
"../../infra/outbound/deliver-runtime.js",
@@ -79,67 +25,9 @@ vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
};
});
const actualDeliver = await vi.importActual<
typeof import("../../infra/outbound/deliver-runtime.js")
>("../../infra/outbound/deliver-runtime.js");
const { routeReply } = await import("./route-reply.js");
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
commands: [],
channels,
channelSetups: channels.map((entry) => ({
pluginId: entry.pluginId,
plugin: entry.plugin,
source: entry.source,
enabled: true,
})),
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});
const createMSTeamsOutbound = (): ChannelOutboundAdapter => ({
deliveryMode: "direct",
sendText: async ({ cfg, to, text }) => {
const result = await mocks.sendMessageMSTeams({ cfg, to, text });
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
const result = await mocks.sendMessageMSTeams({ cfg, to, text, mediaUrl });
return { channel: "msteams", ...result };
},
});
const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Teams SDK; enterprise support.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: params.outbound,
});
const slackMessaging: ChannelMessagingAdapter = {
enableInteractiveReplies: ({ cfg }) =>
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
@@ -160,32 +48,36 @@ const slackThreading: ChannelThreadingAdapter = {
}),
};
const mattermostOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => {
const result = await mocks.sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
});
return { channel: "mattermost", ...result };
},
sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => {
const result = await mocks.sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
mediaUrl,
});
return { channel: "mattermost", ...result };
},
};
function createChannelPlugin(
id: ChannelPlugin["id"],
options: {
messaging?: ChannelMessagingAdapter;
threading?: ChannelThreadingAdapter;
label?: string;
} = {},
): ChannelPlugin {
return {
...createChannelTestPluginBase({
id,
label: options.label ?? String(id),
config: { listAccountIds: () => [], resolveAccount: () => ({}) },
}),
...(options.messaging ? { messaging: options.messaging } : {}),
...(options.threading ? { threading: options.threading } : {}),
};
}
async function expectSlackNoSend(
function expectLastDelivery(
matcher: Partial<Parameters<(typeof mocks.deliverOutboundPayloads.mock.calls)[number][0]>[0]>,
) {
expect(mocks.deliverOutboundPayloads).toHaveBeenLastCalledWith(expect.objectContaining(matcher));
}
async function expectSlackNoDelivery(
payload: Parameters<typeof routeReply>[0]["payload"],
overrides: Partial<Parameters<typeof routeReply>[0]> = {},
) {
mocks.sendMessageSlack.mockClear();
mocks.deliverOutboundPayloads.mockClear();
const res = await routeReply({
payload,
channel: "slack",
@@ -194,22 +86,69 @@ async function expectSlackNoSend(
...overrides,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
return res;
}
describe("routeReply", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: createChannelPlugin("discord", { label: "Discord" }),
source: "test",
},
{
pluginId: "slack",
plugin: createChannelPlugin("slack", {
label: "Slack",
messaging: slackMessaging,
threading: slackThreading,
}),
source: "test",
},
{
pluginId: "telegram",
plugin: createChannelPlugin("telegram", { label: "Telegram" }),
source: "test",
},
{
pluginId: "whatsapp",
plugin: createChannelPlugin("whatsapp", { label: "WhatsApp" }),
source: "test",
},
{
pluginId: "signal",
plugin: createChannelPlugin("signal", { label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createChannelPlugin("imessage", { label: "iMessage" }),
source: "test",
},
{
pluginId: "msteams",
plugin: createChannelPlugin("msteams", { label: "Microsoft Teams" }),
source: "test",
},
{
pluginId: "mattermost",
plugin: createChannelPlugin("mattermost", { label: "Mattermost" }),
source: "test",
},
]),
);
mocks.deliverOutboundPayloads.mockReset();
mocks.deliverOutboundPayloads.mockResolvedValue([]);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
setActivePluginRegistry(createTestRegistry());
});
it("skips sends when abort signal is already aborted", async () => {
mocks.sendMessageSlack.mockClear();
const controller = new AbortController();
controller.abort();
const res = await routeReply({
@@ -221,23 +160,22 @@ describe("routeReply", () => {
});
expect(res.ok).toBe(false);
expect(res.error).toContain("aborted");
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
});
it("no-ops on empty payload", async () => {
await expectSlackNoSend({});
await expectSlackNoDelivery({});
});
it("suppresses reasoning payloads", async () => {
await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true });
await expectSlackNoDelivery({ text: "Reasoning:\n_step_", isReasoning: true });
});
it("drops silent token payloads", async () => {
await expectSlackNoSend({ text: SILENT_REPLY_TOKEN });
await expectSlackNoDelivery({ text: SILENT_REPLY_TOKEN });
});
it("does not drop payloads that merely start with the silent token", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
channel: "slack",
@@ -245,15 +183,18 @@ describe("routeReply", () => {
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
`${SILENT_REPLY_TOKEN} -- (why am I here?)`,
expect.any(Object),
);
expectLastDelivery({
channel: "slack",
to: "channel:C123",
payloads: [
expect.objectContaining({
text: `${SILENT_REPLY_TOKEN} -- (why am I here?)`,
}),
],
});
});
it("applies responsePrefix when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
messages: { responsePrefix: "[openclaw]" },
} as unknown as OpenClawConfig;
@@ -263,15 +204,12 @@ describe("routeReply", () => {
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[openclaw] hi",
expect.any(Object),
);
expectLastDelivery({
payloads: [expect.objectContaining({ text: "[openclaw] hi" })],
});
});
it("routes directive-only Slack replies when interactive replies are enabled", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
channels: {
slack: {
@@ -285,22 +223,25 @@ describe("routeReply", () => {
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "actions",
block_id: "openclaw_reply_select_1",
}),
],
}),
);
expectLastDelivery({
payloads: [
expect.objectContaining({
text: undefined,
interactive: {
blocks: [
expect.objectContaining({
type: "select",
placeholder: "Choose one",
}),
],
},
}),
],
});
});
it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
await expectSlackNoSend({
await expectSlackNoDelivery({
text: " ",
channelData: {
slack: {
@@ -311,13 +252,12 @@ describe("routeReply", () => {
});
it("does not derive responsePrefix from agent identity when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
agents: {
list: [
{
id: "rich",
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
identity: { name: "Richbot", theme: "lion bot", emoji: "lion" },
},
],
},
@@ -330,11 +270,12 @@ describe("routeReply", () => {
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
expectLastDelivery({
payloads: [expect.objectContaining({ text: "hi" })],
});
});
it("uses threadId for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
@@ -342,15 +283,14 @@ describe("routeReply", () => {
threadId: "456.789",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "456.789" }),
);
expectLastDelivery({
channel: "slack",
replyToId: "456.789",
threadId: null,
});
});
it("passes thread id to Telegram sends", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "telegram",
@@ -358,65 +298,54 @@ describe("routeReply", () => {
threadId: 42,
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:123",
threadId: 42,
}),
);
expectLastDelivery({
channel: "telegram",
to: "telegram:123",
threadId: 42,
});
});
it("formats BTW replies prominently on routed sends", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
expectLastDelivery({
channel: "slack",
payloads: [expect.objectContaining({ text: "BTW\nQuestion: what is 17 * 19?\n\n323" })],
});
});
it("formats BTW replies prominently on routed discord sends", async () => {
mocks.sendMessageDiscord.mockClear();
await routeReply({
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
channel: "discord",
to: "channel:123456",
cfg: {} as never,
});
expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
"channel:123456",
"BTW\nQuestion: what is 17 * 19?\n\n323",
expect.any(Object),
);
expectLastDelivery({
channel: "discord",
payloads: [expect.objectContaining({ text: "BTW\nQuestion: what is 17 * 19?\n\n323" })],
});
});
it("passes replyToId to Telegram sends", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi", replyToId: "123" },
channel: "telegram",
to: "telegram:123",
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:123",
replyToId: "123",
}),
);
expectLastDelivery({
channel: "telegram",
to: "telegram:123",
replyToId: "123",
});
});
it("preserves audioAsVoice on routed outbound payloads", async () => {
mocks.deliverOutboundPayloads.mockClear();
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "voice caption", mediaUrl: "file:///tmp/clip.mp3", audioAsVoice: true },
channel: "slack",
@@ -424,38 +353,34 @@ describe("routeReply", () => {
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
to: "channel:C123",
payloads: [
expect.objectContaining({
text: "voice caption",
mediaUrl: "file:///tmp/clip.mp3",
audioAsVoice: true,
}),
],
}),
);
expectLastDelivery({
channel: "slack",
to: "channel:C123",
payloads: [
expect.objectContaining({
text: "voice caption",
mediaUrl: "file:///tmp/clip.mp3",
audioAsVoice: true,
}),
],
});
});
it("uses replyToId as threadTs for Slack", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi", replyToId: "1710000000.0001" },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "1710000000.0001" }),
);
expectLastDelivery({
channel: "slack",
replyToId: "1710000000.0001",
threadId: null,
});
});
it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
@@ -463,15 +388,14 @@ describe("routeReply", () => {
threadId: "1710000000.9999",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "1710000000.9999" }),
);
expectLastDelivery({
channel: "slack",
replyToId: "1710000000.9999",
threadId: null,
});
});
it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "mattermost",
@@ -487,41 +411,33 @@ describe("routeReply", () => {
},
} as unknown as OpenClawConfig,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "mattermost",
to: "channel:CHAN1",
replyToId: "post-root",
threadId: "post-root",
}),
);
expectLastDelivery({
channel: "mattermost",
to: "channel:CHAN1",
replyToId: "post-root",
threadId: "post-root",
});
});
it("sends multiple mediaUrls (caption only on first)", async () => {
mocks.sendMessageSlack.mockClear();
it("preserves multiple mediaUrls as a single outbound payload", async () => {
await routeReply({
payload: { text: "caption", mediaUrls: ["a", "b"] },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledTimes(2);
expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
1,
"channel:C123",
"caption",
expect.objectContaining({ mediaUrl: "a" }),
);
expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
2,
"channel:C123",
"",
expect.objectContaining({ mediaUrl: "b" }),
);
expectLastDelivery({
channel: "slack",
payloads: [
expect.objectContaining({
text: "caption",
mediaUrls: ["a", "b"],
}),
],
});
});
it("routes WhatsApp via outbound sender (accountId honored)", async () => {
mocks.sendMessageWhatsApp.mockClear();
it("routes WhatsApp with the account id intact", async () => {
await routeReply({
payload: { text: "hi" },
channel: "whatsapp",
@@ -529,26 +445,14 @@ describe("routeReply", () => {
accountId: "acc-1",
cfg: {} as never,
});
expect(mocks.sendMessageWhatsApp).toHaveBeenCalledWith(
"+15551234567",
"hi",
expect.objectContaining({ accountId: "acc-1", verbose: false }),
);
expectLastDelivery({
channel: "whatsapp",
to: "+15551234567",
accountId: "acc-1",
});
});
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
outbound: createMSTeamsOutbound(),
}),
},
]),
);
it("routes MS Teams via outbound delivery", async () => {
const cfg = {
channels: {
msteams: {
@@ -562,17 +466,15 @@ describe("routeReply", () => {
to: "conversation:19:abc@thread.tacv2",
cfg,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
}),
);
expectLastDelivery({
channel: "msteams",
to: "conversation:19:abc@thread.tacv2",
cfg,
payloads: [expect.objectContaining({ text: "hi" })],
});
});
it("passes mirror data when sessionKey is set", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "slack",
@@ -582,20 +484,17 @@ describe("routeReply", () => {
groupId: "channel:C123",
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: expect.objectContaining({
sessionKey: "agent:main:main",
text: "hi",
isGroup: true,
groupId: "channel:C123",
}),
expectLastDelivery({
mirror: expect.objectContaining({
sessionKey: "agent:main:main",
text: "hi",
isGroup: true,
groupId: "channel:C123",
}),
);
});
});
it("skips mirror data when mirror is false", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "slack",
@@ -604,76 +503,8 @@ describe("routeReply", () => {
mirror: false,
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: undefined,
}),
);
expectLastDelivery({
mirror: undefined,
});
});
});
const emptyRegistry = createRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "discord",
plugin: createOutboundTestPlugin({
id: "discord",
outbound: discordOutbound,
label: "Discord",
}),
source: "test",
},
{
pluginId: "slack",
plugin: {
...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }),
messaging: slackMessaging,
threading: slackThreading,
},
source: "test",
},
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: telegramOutbound,
label: "Telegram",
}),
source: "test",
},
{
pluginId: "whatsapp",
plugin: createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
label: "WhatsApp",
}),
source: "test",
},
{
pluginId: "signal",
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound, label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createIMessageTestPlugin({ outbound: imessageOutbound }),
source: "test",
},
{
pluginId: "msteams",
plugin: createMSTeamsPlugin({
outbound: createMSTeamsOutbound(),
}),
source: "test",
},
{
pluginId: "mattermost",
plugin: createOutboundTestPlugin({
id: "mattermost",
outbound: mattermostOutbound,
label: "Mattermost",
}),
source: "test",
},
]);

View File

@@ -1,141 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { createSlackOutboundPayloadHarness } from "../contracts/suites.js";
function createHarness(params: {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
}) {
return createSlackOutboundPayloadHarness(params);
}
describe("slackOutbound sendPayload", () => {
it("forwards Slack blocks from channelData", async () => {
const { run, sendMock, to } = createHarness({
payload: {
text: "Fallback summary",
channelData: {
slack: {
blocks: [{ type: "divider" }],
},
},
},
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith(
to,
"Fallback summary",
expect.objectContaining({
blocks: [{ type: "divider" }],
}),
);
expect(result).toMatchObject({ channel: "slack", messageId: "sl-1" });
});
it("accepts blocks encoded as JSON strings in Slack channelData", async () => {
const { run, sendMock, to } = createHarness({
payload: {
channelData: {
slack: {
blocks: '[{"type":"section","text":{"type":"mrkdwn","text":"hello"}}]',
},
},
},
});
await run();
expect(sendMock).toHaveBeenCalledWith(
to,
"",
expect.objectContaining({
blocks: [{ type: "section", text: { type: "mrkdwn", text: "hello" } }],
}),
);
});
it("rejects invalid Slack blocks from channelData", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: {},
},
},
},
});
await expect(run()).rejects.toThrow(/blocks must be an array/i);
expect(sendMock).not.toHaveBeenCalled();
});
it("sends media before a separate interactive blocks message", async () => {
const { run, sendMock, to } = createHarness({
payload: {
text: "Approval required",
mediaUrl: "https://example.com/image.png",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
},
],
},
},
sendResults: [{ messageId: "sl-media" }, { messageId: "sl-controls" }],
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock).toHaveBeenNthCalledWith(
1,
to,
"",
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
}),
);
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("blocks");
expect(sendMock).toHaveBeenNthCalledWith(
2,
to,
"Approval required",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "actions",
}),
],
}),
);
expect(result).toMatchObject({ channel: "slack", messageId: "sl-controls" });
});
it("fails when merged Slack blocks exceed the platform limit", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: Array.from({ length: 50 }, () => ({ type: "divider" })),
},
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
},
],
},
},
});
await expect(run()).rejects.toThrow(/Slack blocks cannot exceed 50 items/i);
expect(sendMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
vi.mock("../../../../extensions/slack/test-api.js", () => ({
sendMessageSlack: vi.fn().mockResolvedValue({ messageId: "1234.5678", channelId: "C123" }),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
getGlobalHookRunner: vi.fn(),
}));
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { sendMessageSlack } from "../../../../extensions/slack/test-api.js";
import { slackOutbound } from "../../../../test/channel-outbounds.js";
type SlackSendTextCtx = {
to: string;
text: string;
accountId: string;
replyToId: string;
identity?: {
name?: string;
avatarUrl?: string;
emoji?: string;
};
};
const BASE_SLACK_SEND_CTX = {
to: "C123",
accountId: "default",
replyToId: "1111.2222",
} as const;
const sendSlackText = async (ctx: SlackSendTextCtx) => {
const sendText = slackOutbound.sendText as NonNullable<typeof slackOutbound.sendText>;
return await sendText({
cfg: {} as OpenClawConfig,
...ctx,
});
};
const sendSlackTextWithDefaults = async (
overrides: Partial<SlackSendTextCtx> & Pick<SlackSendTextCtx, "text">,
) => {
return await sendSlackText({
...BASE_SLACK_SEND_CTX,
...overrides,
});
};
const expectSlackSendCalledWith = (
text: string,
options?: {
identity?: {
username?: string;
iconUrl?: string;
iconEmoji?: string;
};
},
) => {
const expected = {
threadTs: "1111.2222",
accountId: "default",
cfg: expect.any(Object),
...(options?.identity ? { identity: expect.objectContaining(options.identity) } : {}),
};
expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, expect.objectContaining(expected));
};
describe("slack outbound hook wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls send without hooks when no hooks registered", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await sendSlackTextWithDefaults({ text: "hello" });
expectSlackSendCalledWith("hello");
});
it("forwards identity opts when present", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await sendSlackTextWithDefaults({
text: "hello",
identity: {
name: "My Agent",
avatarUrl: "https://example.com/avatar.png",
emoji: ":should_not_send:",
},
});
expectSlackSendCalledWith("hello", {
identity: { username: "My Agent", iconUrl: "https://example.com/avatar.png" },
});
});
it("forwards icon_emoji only when icon_url is absent", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await sendSlackTextWithDefaults({
text: "hello",
identity: { emoji: ":lobster:" },
});
expectSlackSendCalledWith("hello", {
identity: { iconEmoji: ":lobster:" },
});
});
it("calls message_sending hook before sending", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue(undefined),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await sendSlackTextWithDefaults({ text: "hello" });
expect(mockRunner.hasHooks).toHaveBeenCalledWith("message_sending");
expect(mockRunner.runMessageSending).toHaveBeenCalledWith(
{ to: "C123", content: "hello", metadata: { threadTs: "1111.2222", channelId: "C123" } },
{ channelId: "slack", accountId: "default" },
);
expectSlackSendCalledWith("hello");
});
it("cancels send when hook returns cancel:true", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ cancel: true }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
const result = await sendSlackTextWithDefaults({ text: "hello" });
expect(sendMessageSlack).not.toHaveBeenCalled();
expect(result.channel).toBe("slack");
});
it("modifies text when hook returns content", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ content: "modified" }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await sendSlackTextWithDefaults({ text: "original" });
expectSlackSendCalledWith("modified");
});
it("skips hooks when runner has no message_sending hooks", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(false),
runMessageSending: vi.fn(),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await sendSlackTextWithDefaults({ text: "hello" });
expect(mockRunner.runMessageSending).not.toHaveBeenCalled();
expect(sendMessageSlack).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { normalizeSignalAccountInput } from "../../../extensions/signal/api.js";
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js";
@@ -187,32 +186,3 @@ describe("whatsappOutbound.resolveTarget", () => {
});
});
});
describe("normalizeSignalAccountInput", () => {
it("accepts already normalized numbers", () => {
expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123");
});
it("normalizes formatted input", () => {
expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234");
});
it("rejects empty input", () => {
expect(normalizeSignalAccountInput(" ")).toBeNull();
});
it("rejects non-numeric input", () => {
expect(normalizeSignalAccountInput("ok")).toBeNull();
expect(normalizeSignalAccountInput("++--")).toBeNull();
});
it("rejects inputs with stray + characters", () => {
expect(normalizeSignalAccountInput("++12345")).toBeNull();
expect(normalizeSignalAccountInput("+1+2345")).toBeNull();
});
it("rejects numbers that are too short or too long", () => {
expect(normalizeSignalAccountInput("+1234")).toBeNull();
expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull();
});
});

View File

@@ -36,9 +36,6 @@ async function collectDoctorWarnings(config: Record<string, unknown>): Promise<s
}
type DoctorFlowDeps = {
telegramFetchModule: typeof import("../../extensions/telegram/src/fetch.js");
telegramProxyModule: typeof import("../../extensions/telegram/src/proxy.js");
commandSecretGatewayModule: typeof import("../cli/command-secret-gateway.js");
noteModule: typeof import("../terminal/note.js");
loadAndMaybeMigrateDoctorConfig: typeof import("./doctor-config-flow.js").loadAndMaybeMigrateDoctorConfig;
};
@@ -49,15 +46,9 @@ async function loadFreshDoctorFlowDeps(): Promise<DoctorFlowDeps> {
if (!cachedDoctorFlowDeps) {
vi.resetModules();
cachedDoctorFlowDeps = (async () => {
const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js");
const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js");
const freshCommandSecretGatewayModule = await import("../cli/command-secret-gateway.js");
const freshNoteModule = await import("../terminal/note.js");
const doctorFlowModule = await import("./doctor-config-flow.js");
return {
telegramFetchModule,
telegramProxyModule,
commandSecretGatewayModule: freshCommandSecretGatewayModule,
noteModule: freshNoteModule,
loadAndMaybeMigrateDoctorConfig: doctorFlowModule.loadAndMaybeMigrateDoctorConfig,
};
@@ -603,244 +594,22 @@ describe("doctor config flow", () => {
};
expect(cfg.channels.discord.streaming).toBe("partial");
expect(cfg.channels.discord.streamMode).toBeUndefined();
expect(cfg.channels.discord.lifecycle).toBeUndefined();
});
it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
const globalFetch = vi.fn(async () => {
throw new Error("global fetch should not be called");
expect(cfg.channels.discord.lifecycle).toEqual({
enabled: true,
reactions: {
queued: "⏳",
thinking: "🧠",
tool: "🔧",
done: "✅",
error: "❌",
},
});
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
const u = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
const chatId = new URL(u).searchParams.get("chat_id") ?? "";
const id =
chatId.toLowerCase() === "@testuser"
? 111
: chatId.toLowerCase() === "@groupuser"
? 222
: chatId.toLowerCase() === "@topicuser"
? 333
: chatId.toLowerCase() === "@accountuser"
? 444
: null;
return {
ok: id != null,
json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }),
} as unknown as Response;
});
vi.stubGlobal("fetch", globalFetch);
const {
telegramFetchModule,
telegramProxyModule,
loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh,
} = await loadFreshDoctorFlowDeps();
const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch");
const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch");
resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch);
try {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
telegram: {
botToken: "123:abc",
allowFrom: ["@testuser"],
groupAllowFrom: ["groupUser"],
groups: {
"-100123": {
allowFrom: ["tg:@topicUser"],
topics: { "99": { allowFrom: ["@accountUser"] } },
},
},
accounts: {
alerts: { botToken: "456:def", allowFrom: ["@accountUser"] },
},
},
},
},
run: loadDoctorFlowFresh,
});
const cfg = result.cfg as unknown as {
channels: {
telegram: {
allowFrom?: string[];
groupAllowFrom?: string[];
groups: Record<
string,
{ allowFrom: string[]; topics: Record<string, { allowFrom: string[] }> }
>;
accounts: Record<string, { allowFrom?: string[]; groupAllowFrom?: string[] }>;
};
};
};
expect(cfg.channels.telegram.allowFrom).toBeUndefined();
expect(cfg.channels.telegram.groupAllowFrom).toBeUndefined();
expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]);
expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]);
expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]);
expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]);
expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]);
} finally {
makeProxyFetch.mockRestore();
resolveTelegramFetch.mockRestore();
vi.unstubAllGlobals();
}
});
it("does not crash when Telegram allowFrom repair sees unavailable SecretRef-backed credentials", async () => {
const { noteModule: freshNoteModule, loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh } =
await loadFreshDoctorFlowDeps();
const noteSpy = vi.spyOn(freshNoteModule, "note").mockImplementation(() => {});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
try {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
secrets: {
providers: {
default: { source: "env" },
},
},
channels: {
telegram: {
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
allowFrom: ["@testuser"],
},
},
},
run: loadDoctorFlowFresh,
});
const cfg = result.cfg as {
channels?: {
telegram?: {
allowFrom?: string[];
accounts?: Record<string, { allowFrom?: string[] }>;
};
};
};
const retainedAllowFrom =
cfg.channels?.telegram?.accounts?.default?.allowFrom ?? cfg.channels?.telegram?.allowFrom;
expect(retainedAllowFrom).toEqual(["@testuser"]);
expect(fetchSpy).not.toHaveBeenCalled();
expect(
noteSpy.mock.calls.some((call) =>
String(call[0]).includes(
"configured Telegram bot credentials are unavailable in this command path",
),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
vi.unstubAllGlobals();
}
});
it("ignores custom Telegram apiRoot and proxy when repairing allowFrom usernames", async () => {
const globalFetch = vi.fn(async () => {
throw new Error("global fetch should not be called");
});
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
expect(url).toBe("https://api.telegram.org/bottok/getChat?chat_id=%40testuser");
return {
ok: true,
json: async () => ({ ok: true, result: { id: 12345 } }),
};
});
vi.stubGlobal("fetch", globalFetch);
const proxyFetch = vi.fn();
const {
telegramFetchModule,
telegramProxyModule,
commandSecretGatewayModule: freshCommandSecretGatewayModule,
loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh,
} = await loadFreshDoctorFlowDeps();
const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch");
const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch");
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch);
const resolveSecretsSpy = vi
.spyOn(freshCommandSecretGatewayModule, "resolveCommandSecretRefsViaGateway")
.mockResolvedValue({
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
resolvedConfig: {
channels: {
telegram: {
accounts: {
work: {
botToken: "tok",
apiRoot: "https://custom.telegram.test/root/",
proxy: "http://127.0.0.1:8888",
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
allowFrom: ["@testuser"],
},
},
},
},
},
});
try {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
telegram: {
accounts: {
work: {
botToken: "tok",
allowFrom: ["@testuser"],
},
},
},
},
},
run: loadDoctorFlowFresh,
});
const cfg = result.cfg as {
channels?: {
telegram?: {
accounts?: Record<string, { allowFrom?: string[] }>;
};
};
};
expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]);
expect(makeProxyFetch).not.toHaveBeenCalled();
expect(resolveTelegramFetch).toHaveBeenCalledWith(undefined, {
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
} finally {
makeProxyFetch.mockRestore();
resolveTelegramFetch.mockRestore();
resolveSecretsSpy.mockRestore();
vi.unstubAllGlobals();
}
});
it("sanitizes config-derived doctor warnings and changes before logging", async () => {
const {
telegramFetchModule,
noteModule: freshNoteModule,
loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh,
} = await loadFreshDoctorFlowDeps();
const { noteModule: freshNoteModule, loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh } =
await loadFreshDoctorFlowDeps();
const noteSpy = vi.spyOn(freshNoteModule, "note").mockImplementation(() => {});
const globalFetch = vi.fn(async () => {
throw new Error("global fetch should not be called");
});
const fetchSpy = vi.fn(async () => ({
ok: true,
json: async () => ({ ok: true, result: { id: 12345 } }),
}));
vi.stubGlobal("fetch", globalFetch);
const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch");
resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch);
try {
await runDoctorConfigWithInput({
repair: true,
@@ -881,7 +650,6 @@ describe("doctor config flow", () => {
.map((call) => String(call[0]));
expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]);
expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]);
expect(outputs.some((line) => line.includes("resolved @testuser -> 12345"))).toBe(true);
expect(
outputs.some(
(line) =>
@@ -904,9 +672,7 @@ describe("doctor config flow", () => {
),
).toBe(true);
} finally {
resolveTelegramFetch.mockRestore();
noteSpy.mockRestore();
vi.unstubAllGlobals();
}
});
@@ -1431,7 +1197,15 @@ describe("doctor config flow", () => {
},
run: loadAndMaybeMigrateDoctorConfig,
});
expectGoogleChatDmAllowFromRepaired(result.cfg);
const cfg = result.cfg as {
channels: {
googlechat: {
dm: { allowFrom: string[] };
allowFrom?: string[];
};
};
};
expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]);
expect(cfg.channels.googlechat.allowFrom).toEqual(["*"]);
});
});

View File

@@ -1,12 +1,90 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { TelegramNetworkConfig } from "../../../config/types.telegram.js";
const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn());
const listTelegramAccountIdsMock = vi.hoisted(() => vi.fn());
const inspectTelegramAccountMock = vi.hoisted(() => vi.fn());
const lookupTelegramChatIdMock = vi.hoisted(() => vi.fn());
const resolveTelegramAccountMock = vi.hoisted(() => vi.fn());
vi.mock("../../../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: resolveCommandSecretRefsViaGatewayMock,
}));
vi.mock("../../../plugin-sdk/telegram.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../plugin-sdk/telegram.js")>();
return {
...actual,
listTelegramAccountIds: listTelegramAccountIdsMock,
inspectTelegramAccount: inspectTelegramAccountMock,
lookupTelegramChatId: lookupTelegramChatIdMock,
};
});
vi.mock("../../../plugin-sdk/account-resolution.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../plugin-sdk/account-resolution.js")>();
return {
...actual,
resolveTelegramAccount: resolveTelegramAccountMock,
};
});
import {
collectTelegramAllowFromUsernameWarnings,
collectTelegramEmptyAllowlistExtraWarnings,
collectTelegramGroupPolicyWarnings,
maybeRepairTelegramAllowFromUsernames,
scanTelegramAllowFromUsernameEntries,
} from "./telegram.js";
describe("doctor telegram provider warnings", () => {
beforeEach(() => {
resolveCommandSecretRefsViaGatewayMock.mockReset().mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
}));
listTelegramAccountIdsMock.mockReset().mockImplementation((cfg: OpenClawConfig) => {
const telegram = cfg.channels?.telegram;
const accountIds = Object.keys(telegram?.accounts ?? {});
return accountIds.length > 0 ? ["default", ...accountIds] : ["default"];
});
inspectTelegramAccountMock
.mockReset()
.mockImplementation((_params: { cfg: OpenClawConfig; accountId: string }) => ({
enabled: true,
tokenStatus: "configured",
}));
resolveTelegramAccountMock
.mockReset()
.mockImplementation((params: { cfg: OpenClawConfig; accountId?: string | null }) => {
const accountId = params.accountId?.trim() || "default";
const telegram = params.cfg.channels?.telegram ?? {};
const account =
accountId === "default"
? telegram
: ((telegram.accounts?.[accountId] as Record<string, unknown> | undefined) ?? {});
const token =
typeof account.botToken === "string"
? account.botToken
: typeof telegram.botToken === "string"
? telegram.botToken
: "";
return {
accountId,
token,
tokenSource: token ? "config" : "none",
config:
account && typeof account === "object" && "network" in account
? { network: account.network as TelegramNetworkConfig | undefined }
: {},
};
});
lookupTelegramChatIdMock.mockReset();
});
it("shows first-run guidance when groups are not configured yet", () => {
const warnings = collectTelegramGroupPolicyWarnings({
account: {
@@ -133,4 +211,188 @@ describe("doctor telegram provider warnings", () => {
expect.stringContaining('Run "openclaw doctor --fix"'),
]);
});
it("repairs Telegram @username allowFrom entries to numeric ids", async () => {
lookupTelegramChatIdMock.mockImplementation(async ({ chatId }: { chatId: string }) => {
switch (chatId.toLowerCase()) {
case "@testuser":
return "111";
case "@groupuser":
return "222";
case "@topicuser":
return "333";
case "@accountuser":
return "444";
default:
return null;
}
});
const result = await maybeRepairTelegramAllowFromUsernames({
channels: {
telegram: {
botToken: "123:abc",
allowFrom: ["@testuser"],
groupAllowFrom: ["groupUser"],
groups: {
"-100123": {
allowFrom: ["tg:@topicUser"],
topics: { "99": { allowFrom: ["@accountUser"] } },
},
},
accounts: {
alerts: { botToken: "456:def", allowFrom: ["@accountUser"] },
},
},
},
});
const cfg = result.config as {
channels: {
telegram: {
allowFrom?: string[];
groupAllowFrom?: string[];
groups: Record<
string,
{ allowFrom: string[]; topics: Record<string, { allowFrom: string[] }> }
>;
accounts: Record<string, { allowFrom?: string[] }>;
};
};
};
expect(cfg.channels.telegram.allowFrom).toEqual(["111"]);
expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]);
expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]);
expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]);
expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]);
});
it("sanitizes Telegram allowFrom repair change lines before logging", async () => {
lookupTelegramChatIdMock.mockImplementation(async ({ chatId }: { chatId: string }) => {
if (chatId === "@\u001b[31mtestuser") {
return "12345";
}
return null;
});
const result = await maybeRepairTelegramAllowFromUsernames({
channels: {
telegram: {
botToken: "123:abc",
allowFrom: ["@\u001b[31mtestuser"],
},
},
});
expect(result.config.channels?.telegram?.allowFrom).toEqual(["12345"]);
expect(result.changes.some((line) => line.includes("\u001b"))).toBe(false);
expect(
result.changes.some((line) =>
line.includes("channels.telegram.allowFrom: resolved @testuser -> 12345"),
),
).toBe(true);
});
it("keeps Telegram allowFrom entries unchanged when configured credentials are unavailable", async () => {
inspectTelegramAccountMock.mockImplementation(() => ({
enabled: true,
tokenStatus: "configured_unavailable",
}));
resolveTelegramAccountMock.mockImplementation(() => ({
accountId: "default",
token: "",
tokenSource: "none",
config: {},
}));
const result = await maybeRepairTelegramAllowFromUsernames({
secrets: {
providers: {
default: { source: "env" },
},
},
channels: {
telegram: {
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
allowFrom: ["@testuser"],
},
},
} as unknown as OpenClawConfig);
const cfg = result.config as {
channels?: {
telegram?: {
allowFrom?: string[];
};
};
};
expect(cfg.channels?.telegram?.allowFrom).toEqual(["@testuser"]);
expect(
result.changes.some((line) =>
line.includes("configured Telegram bot credentials are unavailable"),
),
).toBe(true);
expect(lookupTelegramChatIdMock).not.toHaveBeenCalled();
});
it("uses network settings for Telegram allowFrom repair but ignores apiRoot and proxy", async () => {
resolveCommandSecretRefsViaGatewayMock.mockResolvedValue({
resolvedConfig: {
channels: {
telegram: {
accounts: {
work: {
botToken: "tok",
apiRoot: "https://custom.telegram.test/root/",
proxy: "http://127.0.0.1:8888",
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
allowFrom: ["@testuser"],
},
},
},
},
},
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
listTelegramAccountIdsMock.mockImplementation(() => ["work"]);
resolveTelegramAccountMock.mockImplementation(() => ({
accountId: "work",
token: "tok",
tokenSource: "config",
config: {
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
},
}));
lookupTelegramChatIdMock.mockResolvedValue("12345");
const result = await maybeRepairTelegramAllowFromUsernames({
channels: {
telegram: {
accounts: {
work: {
botToken: "tok",
allowFrom: ["@testuser"],
},
},
},
},
});
const cfg = result.config as {
channels?: {
telegram?: {
accounts?: Record<string, { allowFrom?: string[] }>;
};
};
};
expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]);
expect(lookupTelegramChatIdMock).toHaveBeenCalledWith({
token: "tok",
chatId: "@testuser",
signal: expect.any(AbortSignal),
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
});
});
});

View File

@@ -2,22 +2,28 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin as TelegramChannelPlugin } from "../../extensions/telegram/runtime-api.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { HealthSummary } from "./health.js";
let testConfig: Record<string, unknown> = {};
let testStore: Record<string, { updatedAt?: number }> = {};
let buildTokenChannelStatusSummary: typeof import("../../extensions/telegram/runtime-api.js").buildTokenChannelStatusSummary;
let probeTelegram: typeof import("../../extensions/telegram/runtime-api.js").probeTelegram;
let listTelegramAccountIds: typeof import("../../extensions/telegram/src/accounts.js").listTelegramAccountIds;
let resolveTelegramAccount: typeof import("../../extensions/telegram/src/accounts.js").resolveTelegramAccount;
let adaptScopedAccountAccessor: typeof import("../plugin-sdk/channel-config-helpers.js").adaptScopedAccountAccessor;
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
let createChannelTestPluginBase: typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
let createTestRegistry: typeof import("../test-utils/channel-plugins.js").createTestRegistry;
let getHealthSnapshot: typeof import("./health.js").getHealthSnapshot;
type TelegramHealthAccount = {
accountId: string;
token: string;
configured: boolean;
config: {
proxy?: string;
network?: Record<string, unknown>;
apiRoot?: string;
};
};
async function loadFreshHealthModulesForTest() {
vi.resetModules();
vi.doMock("../config/config.js", async (importOriginal) => {
@@ -36,14 +42,7 @@ async function loadFreshHealthModulesForTest() {
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
updateLastRoute: vi.fn().mockResolvedValue(undefined),
}));
vi.doMock("../../extensions/telegram/src/fetch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../extensions/telegram/src/fetch.js")>();
return {
...actual,
resolveTelegramFetch: () => fetch,
};
});
vi.doMock("../../extensions/whatsapp/src/auth-store.js", () => ({
vi.doMock("../../extensions/whatsapp/runtime-api.js", () => ({
webAuthExists: vi.fn(async () => true),
getWebAuthAgeMs: vi.fn(() => 1234),
readWebSelfId: vi.fn(() => ({ e164: null, jid: null })),
@@ -51,28 +50,13 @@ async function loadFreshHealthModulesForTest() {
logoutWeb: vi.fn(),
}));
const [
telegramRuntime,
telegramAccounts,
channelHelpers,
pluginsRuntime,
channelTestUtils,
health,
] = await Promise.all([
import("../../extensions/telegram/runtime-api.js"),
import("../../extensions/telegram/src/accounts.js"),
import("../plugin-sdk/channel-config-helpers.js"),
const [pluginsRuntime, channelTestUtils, health] = await Promise.all([
import("../plugins/runtime.js"),
import("../test-utils/channel-plugins.js"),
import("./health.js"),
]);
return {
buildTokenChannelStatusSummary: telegramRuntime.buildTokenChannelStatusSummary,
probeTelegram: telegramRuntime.probeTelegram,
listTelegramAccountIds: telegramAccounts.listTelegramAccountIds,
resolveTelegramAccount: telegramAccounts.resolveTelegramAccount,
adaptScopedAccountAccessor: channelHelpers.adaptScopedAccountAccessor,
setActivePluginRegistry: pluginsRuntime.setActivePluginRegistry,
createChannelTestPluginBase: channelTestUtils.createChannelTestPluginBase,
createTestRegistry: channelTestUtils.createTestRegistry,
@@ -80,6 +64,148 @@ async function loadFreshHealthModulesForTest() {
};
}
function getTelegramChannelConfig(cfg: Record<string, unknown>) {
const channels = cfg.channels as Record<string, unknown> | undefined;
return (channels?.telegram as Record<string, unknown> | undefined) ?? {};
}
function listTelegramAccountIdsForTest(cfg: Record<string, unknown>): string[] {
const telegram = getTelegramChannelConfig(cfg);
const accounts = telegram.accounts as Record<string, unknown> | undefined;
const ids = Object.keys(accounts ?? {}).filter(Boolean);
return ids.length > 0 ? ids : ["default"];
}
function readTokenFromFile(tokenFile: unknown): string {
if (typeof tokenFile !== "string" || !tokenFile.trim()) {
return "";
}
try {
return fs.readFileSync(tokenFile, "utf8").trim();
} catch {
return "";
}
}
function resolveTelegramAccountForTest(params: {
cfg: Record<string, unknown>;
accountId?: string | null;
}): TelegramHealthAccount {
const telegram = getTelegramChannelConfig(params.cfg);
const accounts = (telegram.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
const accountId = params.accountId?.trim() || "default";
const channelConfig = { ...telegram };
delete (channelConfig as { accounts?: unknown }).accounts;
const merged = {
...channelConfig,
...accounts[accountId],
};
const tokenFromConfig =
typeof merged.botToken === "string" && merged.botToken.trim() ? merged.botToken.trim() : "";
const token =
tokenFromConfig ||
readTokenFromFile(merged.tokenFile) ||
(accountId === "default" ? (process.env.TELEGRAM_BOT_TOKEN?.trim() ?? "") : "");
return {
accountId,
token,
configured: token.length > 0,
config: {
...(typeof merged.proxy === "string" && merged.proxy.trim()
? { proxy: merged.proxy.trim() }
: {}),
...(merged.network && typeof merged.network === "object" && !Array.isArray(merged.network)
? { network: merged.network as Record<string, unknown> }
: {}),
...(typeof merged.apiRoot === "string" && merged.apiRoot.trim()
? { apiRoot: merged.apiRoot.trim() }
: {}),
},
};
}
function buildTelegramHealthSummary(snapshot: {
accountId: string;
configured?: boolean;
probe?: unknown;
lastProbeAt?: number | null;
}) {
const probeRecord =
snapshot.probe && typeof snapshot.probe === "object"
? (snapshot.probe as Record<string, unknown>)
: null;
return {
accountId: snapshot.accountId,
configured: Boolean(snapshot.configured),
...(probeRecord ? { probe: probeRecord } : {}),
...(snapshot.lastProbeAt ? { lastProbeAt: snapshot.lastProbeAt } : {}),
};
}
async function probeTelegramAccountForTest(
account: TelegramHealthAccount,
timeoutMs: number,
): Promise<Record<string, unknown>> {
const started = Date.now();
const apiRoot = account.config.apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org";
const base = `${apiRoot}/bot${account.token}`;
try {
const meRes = await fetch(`${base}/getMe`, { signal: AbortSignal.timeout(timeoutMs) });
const meJson = (await meRes.json()) as {
ok?: boolean;
description?: string;
result?: { id?: number; username?: string };
};
if (!meRes.ok || !meJson.ok) {
return {
ok: false,
status: meRes.status,
error: meJson.description ?? `getMe failed (${meRes.status})`,
elapsedMs: Date.now() - started,
};
}
let webhook: { url?: string | null; hasCustomCert?: boolean | null } | undefined;
try {
const webhookRes = await fetch(`${base}/getWebhookInfo`, {
signal: AbortSignal.timeout(timeoutMs),
});
const webhookJson = (await webhookRes.json()) as {
ok?: boolean;
result?: { url?: string; has_custom_certificate?: boolean };
};
if (webhookRes.ok && webhookJson.ok) {
webhook = {
url: webhookJson.result?.url ?? null,
hasCustomCert: webhookJson.result?.has_custom_certificate ?? null,
};
}
} catch {
// ignore webhook errors in probe flow
}
return {
ok: true,
status: null,
error: null,
elapsedMs: Date.now() - started,
bot: {
id: meJson.result?.id ?? null,
username: meJson.result?.username ?? null,
},
...(webhook ? { webhook } : {}),
};
} catch (error) {
return {
ok: false,
status: null,
error: error instanceof Error ? error.message : String(error),
elapsedMs: Date.now() - started,
};
}
}
function stubTelegramFetchOk(calls: string[]) {
vi.stubGlobal(
"fetch",
@@ -145,24 +271,21 @@ async function runSuccessfulTelegramProbe(
}
function createTelegramHealthPlugin(): Pick<
TelegramChannelPlugin,
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "status"
> {
return {
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
config: {
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount),
isConfigured: (account) => Boolean(account.token?.trim()),
listAccountIds: (cfg) => listTelegramAccountIdsForTest(cfg as Record<string, unknown>),
resolveAccount: (cfg, accountId) =>
resolveTelegramAccountForTest({ cfg: cfg as Record<string, unknown>, accountId }),
isConfigured: (account) => Boolean((account as TelegramHealthAccount).token.trim()),
},
status: {
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
buildChannelSummary: ({ snapshot }) => buildTelegramHealthSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
await probeTelegram(account.token, timeoutMs, {
proxyUrl: account.config.proxy,
network: account.config.network,
accountId: account.accountId,
}),
await probeTelegramAccountForTest(account as TelegramHealthAccount, timeoutMs),
},
};
}
@@ -170,11 +293,6 @@ function createTelegramHealthPlugin(): Pick<
describe("getHealthSnapshot", () => {
beforeAll(async () => {
({
buildTokenChannelStatusSummary,
probeTelegram,
listTelegramAccountIds,
resolveTelegramAccount,
adaptScopedAccountAccessor,
setActivePluginRegistry,
createChannelTestPluginBase,
createTestRegistry,

View File

@@ -1,422 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "../../extensions/ollama/api.js";
import type { RuntimeEnv } from "../runtime.js";
import { jsonResponse, requestBodyText, requestUrl } from "../test-helpers/http.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../agents/auth-profiles.js", () => ({
upsertAuthProfileWithLock,
}));
function createOllamaFetchMock(params: {
tags?: string[];
show?: Record<string, number | undefined>;
meResponses?: Response[];
pullResponse?: Response;
tagsError?: Error;
}) {
const meResponses = [...(params.meResponses ?? [])];
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
if (params.tagsError) {
throw params.tagsError;
}
return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) });
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
return contextWindow
? jsonResponse({ model_info: { "llama.context_length": contextWindow } })
: jsonResponse({});
}
if (url.endsWith("/api/me")) {
return meResponses.shift() ?? jsonResponse({ username: "testuser" });
}
if (url.endsWith("/api/pull")) {
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
function createModePrompter(
mode: "local" | "remote",
params?: { confirm?: boolean },
): WizardPrompter {
return {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce(mode),
...(params?.confirm !== undefined
? { confirm: vi.fn().mockResolvedValueOnce(params.confirm) }
: {}),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createSignedOutRemoteFetchMock() {
return createOllamaFetchMock({
tags: ["llama3:8b"],
meResponses: [
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
jsonResponse({ username: "testuser" }),
],
});
}
function createDefaultOllamaConfig(primary: string) {
return {
agents: { defaults: { model: { primary } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
};
}
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
upsertAuthProfileWithLock.mockClear();
});
it("puts suggested local model first in local mode", async () => {
const prompter = createModePrompter("local");
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("glm-4.7-flash");
});
it("puts suggested cloud model first in remote mode", async () => {
const prompter = createModePrompter("remote");
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("kimi-k2.5:cloud");
});
it("mode selection affects model ordering (local)", async () => {
const prompter = createModePrompter("local");
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "glm-4.7-flash"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("glm-4.7-flash");
expect(modelIds).toContain("llama3:8b");
});
it("cloud+local mode triggers /api/me check and opens sign-in URL", async () => {
const prompter = createModePrompter("remote", { confirm: true });
const fetchMock = createSignedOutRemoteFetchMock();
const openUrl = vi.fn(async () => undefined);
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: false, openUrl });
expect(openUrl).toHaveBeenCalledWith("https://ollama.com/signin");
expect(prompter.confirm).toHaveBeenCalled();
});
it("cloud+local mode does not open browser in remote environment", async () => {
const prompter = createModePrompter("remote", { confirm: true });
const fetchMock = createSignedOutRemoteFetchMock();
const openUrl = vi.fn(async () => undefined);
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: true, openUrl });
expect(openUrl).not.toHaveBeenCalled();
});
it("local mode does not trigger cloud auth", async () => {
const prompter = createModePrompter("local");
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags");
expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe(
false,
);
});
it("suggested models appear first in model list (cloud+local)", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b", "glm-4.7-flash", "deepseek-r1:14b"],
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual([
"kimi-k2.5:cloud",
"minimax-m2.5:cloud",
"glm-5:cloud",
"llama3:8b",
"glm-4.7-flash",
"deepseek-r1:14b",
]);
});
it("uses /api/show context windows when building Ollama model configs", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
show: { "llama3:8b": 65536 },
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const model = result.config.models?.providers?.ollama?.models?.find(
(m) => m.id === "llama3:8b",
);
expect(model?.contextWindow).toBe(65536);
});
describe("ensureOllamaModelPulled", () => {
it("pulls model when not available locally", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/glm-4.7-flash"),
model: "ollama/glm-4.7-flash",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain("/api/pull");
});
it("skips pull when model is already available", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["glm-4.7-flash"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/glm-4.7-flash"),
model: "ollama/glm-4.7-flash",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("skips pull for cloud models", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/kimi-k2.5:cloud"),
model: "ollama/kimi-k2.5:cloud",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when model is not an ollama model", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "openai/gpt-4o" } } },
},
model: "openai/gpt-4o",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
it("uses discovered model when requested non-interactive download fails", async () => {
const fetchMock = createOllamaFetchMock({
tags: ["qwen2.5-coder:7b"],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {
agents: {
defaults: {
model: {
primary: "openai/gpt-4o-mini",
fallbacks: ["anthropic/claude-sonnet-4-5"],
},
},
},
},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "missing-model",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(result.agents?.defaults?.model).toEqual({
primary: "ollama/qwen2.5-coder:7b",
fallbacks: ["anthropic/claude-sonnet-4-5"],
});
});
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "ollama/llama3.2:latest",
},
runtime,
});
const pullRequest = fetchMock.mock.calls[1]?.[1];
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/llama3.2:latest" }),
);
});
it("accepts cloud models in non-interactive mode without pulling", async () => {
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "kimi-k2.5:cloud",
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
"kimi-k2.5:cloud",
);
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/kimi-k2.5:cloud" }),
);
});
it("exits when Ollama is unreachable", async () => {
const fetchMock = createOllamaFetchMock({
tagsError: new Error("connect ECONNREFUSED"),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const nextConfig = {};
const result = await configureOllamaNonInteractive({
nextConfig,
opts: {
customBaseUrl: "http://127.0.0.1:11435",
customModelId: "llama3.2:latest",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Ollama could not be reached at http://127.0.0.1:11435."),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(result).toBe(nextConfig);
});
});

View File

@@ -1,196 +0,0 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
applyKilocodeProviderConfig,
applyKilocodeConfig,
KILOCODE_BASE_URL,
KILOCODE_DEFAULT_MODEL_REF,
} from "../../extensions/kilocode/onboard.js";
import { resolveApiKeyForProvider, resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import {
buildKilocodeModelDefinition,
KILOCODE_DEFAULT_MODEL_ID,
KILOCODE_DEFAULT_CONTEXT_WINDOW,
KILOCODE_DEFAULT_MAX_TOKENS,
KILOCODE_DEFAULT_COST,
} from "../plugin-sdk/provider-models.js";
import { captureEnv } from "../test-utils/env.js";
const emptyCfg: OpenClawConfig = {};
const KILOCODE_MODEL_IDS = ["kilo/auto"];
describe("Kilo Gateway provider config", () => {
describe("constants", () => {
it("KILOCODE_BASE_URL points to kilo openrouter endpoint", () => {
expect(KILOCODE_BASE_URL).toBe("https://api.kilo.ai/api/gateway/");
});
it("KILOCODE_DEFAULT_MODEL_REF includes provider prefix", () => {
expect(KILOCODE_DEFAULT_MODEL_REF).toBe("kilocode/kilo/auto");
});
it("KILOCODE_DEFAULT_MODEL_ID is kilo/auto", () => {
expect(KILOCODE_DEFAULT_MODEL_ID).toBe("kilo/auto");
});
});
describe("buildKilocodeModelDefinition", () => {
it("returns correct model shape", () => {
const model = buildKilocodeModelDefinition();
expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID);
expect(model.name).toBe("Kilo Auto");
expect(model.reasoning).toBe(true);
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW);
expect(model.maxTokens).toBe(KILOCODE_DEFAULT_MAX_TOKENS);
expect(model.cost).toEqual(KILOCODE_DEFAULT_COST);
});
});
describe("applyKilocodeProviderConfig", () => {
it("registers kilocode provider with correct baseUrl and api", () => {
const result = applyKilocodeProviderConfig(emptyCfg);
const provider = result.models?.providers?.kilocode;
expect(provider).toBeDefined();
expect(provider?.baseUrl).toBe(KILOCODE_BASE_URL);
expect(provider?.api).toBe("openai-completions");
});
it("includes the default model in the provider model list", () => {
const result = applyKilocodeProviderConfig(emptyCfg);
const provider = result.models?.providers?.kilocode;
const models = provider?.models;
expect(Array.isArray(models)).toBe(true);
const modelIds = models?.map((m) => m.id) ?? [];
expect(modelIds).toContain(KILOCODE_DEFAULT_MODEL_ID);
});
it("surfaces the full Kilo model catalog", () => {
const result = applyKilocodeProviderConfig(emptyCfg);
const provider = result.models?.providers?.kilocode;
const modelIds = provider?.models?.map((m) => m.id) ?? [];
for (const modelId of KILOCODE_MODEL_IDS) {
expect(modelIds).toContain(modelId);
}
});
it("appends missing catalog models to existing Kilo provider config", () => {
const result = applyKilocodeProviderConfig({
models: {
providers: {
kilocode: {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models: [buildKilocodeModelDefinition()],
},
},
},
});
const modelIds = result.models?.providers?.kilocode?.models?.map((m) => m.id) ?? [];
for (const modelId of KILOCODE_MODEL_IDS) {
expect(modelIds).toContain(modelId);
}
});
it("sets Kilo Gateway alias in agent default models", () => {
const result = applyKilocodeProviderConfig(emptyCfg);
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
expect(agentModel).toBeDefined();
expect(agentModel?.alias).toBe("Kilo Gateway");
});
it("preserves existing alias if already set", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
models: {
[KILOCODE_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
},
},
},
};
const result = applyKilocodeProviderConfig(cfg);
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
expect(agentModel?.alias).toBe("My Custom Alias");
});
it("does not change the default model selection", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5" },
},
},
};
const result = applyKilocodeProviderConfig(cfg);
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5");
});
});
describe("applyKilocodeConfig", () => {
it("sets kilocode as the default model", () => {
const result = applyKilocodeConfig(emptyCfg);
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
KILOCODE_DEFAULT_MODEL_REF,
);
});
it("also registers the provider", () => {
const result = applyKilocodeConfig(emptyCfg);
const provider = result.models?.providers?.kilocode;
expect(provider).toBeDefined();
expect(provider?.baseUrl).toBe(KILOCODE_BASE_URL);
});
});
describe("env var resolution", () => {
it("resolves KILOCODE_API_KEY from env", () => {
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret
try {
const result = resolveEnvApiKey("kilocode");
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("test-kilo-key");
expect(result?.source).toContain("KILOCODE_API_KEY");
} finally {
envSnapshot.restore();
}
});
it("returns null when KILOCODE_API_KEY is not set", () => {
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
delete process.env.KILOCODE_API_KEY;
try {
const result = resolveEnvApiKey("kilocode");
expect(result).toBeNull();
} finally {
envSnapshot.restore();
}
});
it("resolves the kilocode api key via resolveApiKeyForProvider", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "kilo-provider-test-key"; // pragma: allowlist secret
try {
const auth = await resolveApiKeyForProvider({
provider: "kilocode",
agentDir,
});
expect(auth.apiKey).toBe("kilo-provider-test-key");
expect(auth.mode).toBe("api-key");
expect(auth.source).toContain("KILOCODE_API_KEY");
} finally {
envSnapshot.restore();
}
});
});
});

View File

@@ -3,137 +3,14 @@ import os from "node:os";
import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
} from "../../extensions/minimax/onboard.js";
import { buildMistralModelDefinition as buildBundledMistralModelDefinition } from "../../extensions/mistral/model-definitions.js";
import {
applyMistralConfig,
applyMistralProviderConfig,
} from "../../extensions/mistral/onboard.js";
import {
applyOpencodeGoConfig,
applyOpencodeGoProviderConfig,
} from "../../extensions/opencode-go/onboard.js";
import {
applyOpencodeZenConfig,
applyOpencodeZenProviderConfig,
} from "../../extensions/opencode/onboard.js";
import {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
} from "../../extensions/openrouter/onboard.js";
import {
applySyntheticConfig,
applySyntheticProviderConfig,
SYNTHETIC_DEFAULT_MODEL_REF,
} from "../../extensions/synthetic/onboard.js";
import {
applyXaiConfig,
applyXaiProviderConfig,
XAI_DEFAULT_MODEL_REF,
} from "../../extensions/xai/onboard.js";
import { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js";
import { applyZaiConfig, applyZaiProviderConfig } from "../../extensions/zai/onboard.js";
import { applyLitellmProviderConfig } from "../../extensions/litellm/onboard.js";
import { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import type { ModelApi } from "../config/types.models.js";
import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js";
import {
OPENROUTER_DEFAULT_MODEL_REF,
setMinimaxApiKey,
writeOAuthCredentials,
} from "../plugins/provider-auth-storage.js";
import {
MISTRAL_DEFAULT_MODEL_REF,
buildMistralModelDefinition as buildCoreMistralModelDefinition,
ZAI_CODING_CN_BASE_URL,
ZAI_GLOBAL_BASE_URL,
} from "../plugins/provider-model-definitions.js";
import { setMinimaxApiKey, writeOAuthCredentials } from "../plugins/provider-auth-storage.js";
import {
createAuthTestLifecycle,
readAuthProfilesForAgent,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
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;
}
const EXPECTED_FALLBACKS = ["anthropic/claude-opus-4-5"] as const;
function createConfigWithFallbacks() {
return {
agents: {
defaults: {
model: { fallbacks: [...EXPECTED_FALLBACKS] },
},
},
};
}
function expectFallbacksPreserved(cfg: ReturnType<typeof applyMinimaxApiConfig>) {
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
}
function expectPrimaryModelPreserved(cfg: ReturnType<typeof applyMinimaxApiProviderConfig>) {
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
"anthropic/claude-opus-4-5",
);
}
function expectAllowlistContains(
cfg: ReturnType<typeof applyOpenrouterProviderConfig>,
key: string,
) {
const models = cfg.agents?.defaults?.models ?? {};
expect(Object.keys(models)).toContain(key);
}
function expectAliasPreserved(
cfg: ReturnType<typeof applyOpenrouterProviderConfig>,
key: string,
alias: string,
) {
expect(cfg.agents?.defaults?.models?.[key]?.alias).toBe(alias);
}
describe("writeOAuthCredentials", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
@@ -395,423 +272,3 @@ describe("applyAuthProfileConfig", () => {
});
});
});
describe("applyMinimaxApiConfig", () => {
it("adds minimax provider with correct settings", () => {
const cfg = applyMinimaxApiConfig({});
expect(cfg.models?.providers?.minimax).toMatchObject({
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
authHeader: true,
});
});
it("keeps reasoning enabled for MiniMax-M2.7", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7");
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
});
it("preserves existing model params when adding alias", () => {
const cfg = applyMinimaxApiConfig(
{
agents: {
defaults: {
models: {
"minimax/MiniMax-M2.7": {
alias: "MiniMax",
params: { custom: "value" },
},
},
},
},
},
"MiniMax-M2.7",
);
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toMatchObject({
alias: "Minimax",
params: { custom: "value" },
});
});
it("merges existing minimax provider models", () => {
const cfg = applyMinimaxApiConfig(
createLegacyProviderConfig({
providerId: "minimax",
api: "openai-completions",
}),
);
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.minimax?.authHeader).toBe(true);
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([
"old-model",
"MiniMax-M2.7",
]);
});
it("preserves other providers when adding minimax", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic-key", // pragma: allowlist secret
api: "anthropic-messages",
models: [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
});
expect(cfg.models?.providers?.anthropic).toBeDefined();
expect(cfg.models?.providers?.minimax).toBeDefined();
});
it("preserves existing models mode", () => {
const cfg = applyMinimaxApiConfig({
models: { mode: "replace", providers: {} },
});
expect(cfg.models?.mode).toBe("replace");
});
});
describe("provider config helpers", () => {
it("does not overwrite existing primary model", () => {
const providerConfigAppliers = [applyMinimaxApiProviderConfig, applyZaiProviderConfig];
for (const applyConfig of providerConfigAppliers) {
const cfg = applyConfig({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
});
expectPrimaryModelPreserved(cfg);
}
});
});
describe("applyZaiConfig", () => {
it("adds zai provider with correct settings", () => {
const cfg = applyZaiConfig({});
expect(cfg.models?.providers?.zai).toMatchObject({
// Default: general (non-coding) endpoint. Coding Plan endpoint is detected during setup.
baseUrl: ZAI_GLOBAL_BASE_URL,
api: "openai-completions",
});
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
expect(ids).toContain("glm-5");
expect(ids).toContain("glm-5-turbo");
expect(ids).toContain("glm-4.7");
expect(ids).toContain("glm-4.7-flash");
expect(ids).toContain("glm-4.7-flashx");
});
it("supports CN endpoint for supported coding models", () => {
for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) {
const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId });
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
}
});
});
describe("applySyntheticConfig", () => {
it("adds synthetic provider with correct settings", () => {
const cfg = applySyntheticConfig({});
expect(cfg.models?.providers?.synthetic).toMatchObject({
baseUrl: "https://api.synthetic.new/anthropic",
api: "anthropic-messages",
});
});
it("merges existing synthetic provider models", () => {
const cfg = applySyntheticProviderConfig(
createLegacyProviderConfig({
providerId: "synthetic",
api: "openai-completions",
}),
);
expect(cfg.models?.providers?.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
expect(cfg.models?.providers?.synthetic?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.synthetic?.apiKey).toBe("old-key");
const ids = cfg.models?.providers?.synthetic?.models.map((m) => m.id);
expect(ids).toContain("old-model");
expect(ids).toContain(SYNTHETIC_DEFAULT_MODEL_ID);
});
});
describe("primary model defaults", () => {
it("sets correct primary model", () => {
const configCases = [
{
getConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed"),
primaryModel: "minimax/MiniMax-M2.7-highspeed",
},
{
getConfig: () => applyZaiConfig({}, { modelId: "glm-5" }),
primaryModel: "zai/glm-5",
},
{
getConfig: () => applySyntheticConfig({}),
primaryModel: SYNTHETIC_DEFAULT_MODEL_REF,
},
] as const;
for (const { getConfig, primaryModel } of configCases) {
const cfg = getConfig();
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel);
}
});
});
describe("applyXiaomiConfig", () => {
it("adds Xiaomi provider with correct settings", () => {
const cfg = applyXiaomiConfig({});
expect(cfg.models?.providers?.xiaomi).toMatchObject({
baseUrl: "https://api.xiaomimimo.com/v1",
api: "openai-completions",
});
expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([
"mimo-v2-flash",
"mimo-v2-pro",
"mimo-v2-omni",
]);
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xiaomi/mimo-v2-flash");
});
it("merges Xiaomi models and keeps existing provider overrides", () => {
const cfg = applyXiaomiProviderConfig(
createLegacyProviderConfig({
providerId: "xiaomi",
api: "openai-completions",
modelId: "custom-model",
modelName: "Custom",
}),
);
expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/v1");
expect(cfg.models?.providers?.xiaomi?.api).toBe("openai-completions");
expect(cfg.models?.providers?.xiaomi?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([
"custom-model",
"mimo-v2-flash",
"mimo-v2-pro",
"mimo-v2-omni",
]);
});
});
describe("applyXaiConfig", () => {
it("adds xAI provider with correct settings", () => {
const cfg = applyXaiConfig({});
expect(cfg.models?.providers?.xai).toMatchObject({
baseUrl: "https://api.x.ai/v1",
api: "openai-completions",
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF);
});
});
describe("applyXaiProviderConfig", () => {
it("merges xAI models and keeps existing provider overrides", () => {
const cfg = applyXaiProviderConfig(
createLegacyProviderConfig({
providerId: "xai",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
}),
);
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-completions");
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual(
expect.arrayContaining([
"custom-model",
"grok-4",
"grok-4-1-fast",
"grok-4.20-beta-latest-reasoning",
"grok-code-fast-1",
]),
);
});
});
describe("applyMistralConfig", () => {
it("adds Mistral provider with correct settings", () => {
const cfg = applyMistralConfig({});
expect(cfg.models?.providers?.mistral).toMatchObject({
baseUrl: "https://api.mistral.ai/v1",
api: "openai-completions",
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
MISTRAL_DEFAULT_MODEL_REF,
);
});
});
describe("applyMistralProviderConfig", () => {
it("merges Mistral models and keeps existing provider overrides", () => {
const cfg = applyMistralProviderConfig(
createLegacyProviderConfig({
providerId: "mistral",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
}),
);
expect(cfg.models?.providers?.mistral?.baseUrl).toBe("https://api.mistral.ai/v1");
expect(cfg.models?.providers?.mistral?.api).toBe("openai-completions");
expect(cfg.models?.providers?.mistral?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.mistral?.models.map((m) => m.id)).toEqual([
"custom-model",
"mistral-large-latest",
]);
const mistralDefault = cfg.models?.providers?.mistral?.models.find(
(model) => model.id === "mistral-large-latest",
);
expect(mistralDefault?.contextWindow).toBe(262144);
expect(mistralDefault?.maxTokens).toBe(16384);
});
it("keeps the core and bundled mistral defaults aligned", () => {
const bundled = buildBundledMistralModelDefinition();
const core = buildCoreMistralModelDefinition();
expect(core).toMatchObject({
id: bundled.id,
contextWindow: bundled.contextWindow,
maxTokens: bundled.maxTokens,
});
});
});
describe("fallback preservation helpers", () => {
it("preserves existing model fallbacks", () => {
const fallbackCases = [applyMinimaxApiConfig, applyXaiConfig, applyMistralConfig] as const;
for (const applyConfig of fallbackCases) {
const cfg = applyConfig(createConfigWithFallbacks());
expectFallbacksPreserved(cfg);
}
});
});
describe("provider alias defaults", () => {
it("adds expected alias for provider defaults", () => {
const aliasCases = [
{
applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7"),
modelRef: "minimax/MiniMax-M2.7",
alias: "Minimax",
},
{
applyConfig: () => applyXaiProviderConfig({}),
modelRef: XAI_DEFAULT_MODEL_REF,
alias: "Grok",
},
{
applyConfig: () => applyMistralProviderConfig({}),
modelRef: MISTRAL_DEFAULT_MODEL_REF,
alias: "Mistral",
},
] as const;
for (const testCase of aliasCases) {
const cfg = testCase.applyConfig();
expect(cfg.agents?.defaults?.models?.[testCase.modelRef]?.alias).toBe(testCase.alias);
}
});
});
describe("allowlist provider helpers", () => {
it("adds allowlist entry and preserves alias", () => {
const providerCases = [
{
applyConfig: applyOpencodeZenProviderConfig,
modelRef: "opencode/claude-opus-4-6",
alias: "My Opus",
},
{
applyConfig: applyOpencodeGoProviderConfig,
modelRef: "opencode-go/kimi-k2.5",
alias: "Kimi",
},
{
applyConfig: applyOpenrouterProviderConfig,
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
alias: "Router",
},
] as const;
for (const { applyConfig, modelRef, alias } of providerCases) {
const withDefault = applyConfig({});
expectAllowlistContains(withDefault, modelRef);
const withAlias = applyConfig({
agents: {
defaults: {
models: {
[modelRef]: { alias },
},
},
},
});
expectAliasPreserved(withAlias, modelRef, alias);
}
});
});
describe("applyLitellmProviderConfig", () => {
it("preserves existing baseUrl and api key while adding the default model", () => {
const cfg = applyLitellmProviderConfig(
createLegacyProviderConfig({
providerId: "litellm",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
baseUrl: "https://litellm.example/v1",
apiKey: " old-key ",
}),
);
expect(cfg.models?.providers?.litellm?.baseUrl).toBe("https://litellm.example/v1");
expect(cfg.models?.providers?.litellm?.api).toBe("openai-completions");
expect(cfg.models?.providers?.litellm?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.litellm?.models.map((m) => m.id)).toEqual([
"custom-model",
"claude-opus-4-6",
]);
});
});
describe("default-model config helpers", () => {
it("sets primary model and preserves existing model fallbacks", () => {
const configCases = [
{
applyConfig: applyOpencodeZenConfig,
primaryModel: "opencode/claude-opus-4-6",
},
{
applyConfig: applyOpencodeGoConfig,
primaryModel: "opencode-go/kimi-k2.5",
},
{
applyConfig: applyOpenrouterConfig,
primaryModel: OPENROUTER_DEFAULT_MODEL_REF,
},
] as const;
for (const { applyConfig, primaryModel } of configCases) {
const cfg = applyConfig({});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel);
const cfgWithFallbacks = applyConfig(createConfigWithFallbacks());
expectFallbacksPreserved(cfgWithFallbacks);
}
});
});

View File

@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { OLLAMA_DEFAULT_BASE_URL } from "../../extensions/ollama/api.js";
import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js";
import type { OpenClawConfig } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
@@ -9,6 +8,8 @@ import {
promptCustomApiConfig,
} from "./onboard-custom.js";
const OLLAMA_DEFAULT_BASE_URL_FOR_TEST = "http://127.0.0.1:11434";
// Mock dependencies
vi.mock("./model-picker.js", () => ({
applyPrimaryModel: vi.fn((cfg) => cfg),
@@ -162,7 +163,7 @@ describe("promptCustomApiConfig", () => {
expect(prompter.text).toHaveBeenCalledWith(
expect.objectContaining({
message: "API Base URL",
initialValue: OLLAMA_DEFAULT_BASE_URL,
initialValue: OLLAMA_DEFAULT_BASE_URL_FOR_TEST,
}),
);
});

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { parseTelegramTarget } from "../../extensions/telegram/api.js";
import type { OpenClawConfig } from "../config/config.js";
import { telegramMessagingForTest } from "../infra/outbound/targets.test-helpers.js";
const mockStore: Record<string, Record<string, unknown>> = {};
@@ -21,16 +21,7 @@ beforeEach(async () => {
getChannelPlugin: vi.fn(() => ({
meta: { label: "Telegram" },
config: {},
messaging: {
parseExplicitTarget: ({ raw }: { raw: string }) => {
const target = parseTelegramTarget(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
},
messaging: telegramMessagingForTest,
outbound: {
resolveTarget: ({ to }: { to?: string }) =>
to ? { ok: true, to } : { ok: false, error: new Error("missing") },

View File

@@ -4,6 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
const whatsappAccountMocks = vi.hoisted(() => ({
resolveWhatsAppAccount: vi.fn<() => { allowFrom: string[] }>(() => ({ allowFrom: [] })),
}));
vi.mock("../../config/sessions.js", () => ({
loadSessionStore: vi.fn().mockReturnValue({}),
resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:test:main"),
@@ -25,7 +29,7 @@ vi.mock("../../pairing/pairing-store.js", () => ({
}));
vi.mock("../../plugin-sdk/whatsapp.js", () => ({
resolveWhatsAppAccount: vi.fn(() => ({ allowFrom: [] })),
resolveWhatsAppAccount: whatsappAccountMocks.resolveWhatsAppAccount,
}));
const mockedModuleIds = [
@@ -40,7 +44,6 @@ import { loadSessionStore } from "../../config/sessions.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
afterAll(() => {
@@ -140,9 +143,7 @@ function setLastSessionEntry(params: {
}
function setWhatsAppAllowFrom(allowFrom: string[]) {
vi.mocked(resolveWhatsAppAccount).mockReturnValue({
allowFrom,
} as unknown as ReturnType<typeof resolveWhatsAppAccount>);
vi.mocked(whatsappAccountMocks.resolveWhatsAppAccount).mockReturnValue({ allowFrom });
}
function setStoredWhatsAppAllowFrom(allowFrom: string[]) {

View File

@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/api.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -27,6 +26,21 @@ afterEach(() => {
});
const emptyRegistry = createTestRegistry([]);
function isDiscordExecApprovalClientEnabledForTest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const accountId = params.accountId?.trim();
const rootConfig = params.cfg.channels?.discord?.execApprovals;
const accountConfig =
accountId && accountId !== "default"
? params.cfg.channels?.discordAccounts?.[accountId]?.execApprovals
: undefined;
const config = accountConfig ?? rootConfig;
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
}
const telegramApprovalPlugin: Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "execApprovals"
@@ -47,7 +61,7 @@ const discordApprovalPlugin: Pick<
execApprovals: {
shouldSuppressForwardingFallback: ({ cfg, target }) =>
target.channel === "discord" &&
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }),
isDiscordExecApprovalClientEnabledForTest({ cfg, accountId: target.accountId }),
},
};
const defaultRegistry = createTestRegistry([

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { parseTelegramTarget } from "../../extensions/telegram/api.js";
import { whatsappOutbound } from "../../test/channel-outbounds.js";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import * as replyModule from "../auto-reply/reply.js";
@@ -28,6 +27,7 @@ import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
import { telegramMessagingForTest } from "./outbound/targets.test-helpers.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
let previousRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
@@ -78,20 +78,7 @@ beforeAll(async () => {
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
},
},
messaging: {
parseExplicitTarget: ({ raw }) => {
const target = parseTelegramTarget(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
inferTargetChatType: ({ to }) => {
const target = parseTelegramTarget(to);
return target.chatType === "unknown" ? undefined : target.chatType;
},
},
messaging: telegramMessagingForTest,
});
telegramPlugin.config = {
...telegramPlugin.config,

View File

@@ -1,6 +1,5 @@
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { markdownToSignalTextChunks } from "../../../extensions/signal/api.js";
import {
signalOutbound,
telegramOutbound,
@@ -631,7 +630,6 @@ describe("deliverOutboundPayloads", () => {
channels: { signal: { textChunkLimit: 20 } },
};
const text = `Intro\\n\\n\`\`\`\`md\\n${"y".repeat(60)}\\n\`\`\`\\n\\nOutro`;
const expectedChunks = markdownToSignalTextChunks(text, 20);
await deliverOutboundPayloads({
cfg,
@@ -641,19 +639,18 @@ describe("deliverOutboundPayloads", () => {
deps: { sendSignal },
});
expect(sendSignal).toHaveBeenCalledTimes(expectedChunks.length);
expectedChunks.forEach((chunk, index) => {
expect(sendSignal).toHaveBeenNthCalledWith(
index + 1,
"+1555",
chunk.text,
expect.objectContaining({
accountId: undefined,
textMode: "plain",
textStyles: chunk.styles,
}),
);
expect(sendSignal.mock.calls.length).toBeGreaterThan(1);
const sentTexts = sendSignal.mock.calls.map((call) => call[1]);
sendSignal.mock.calls.forEach((call) => {
const opts = call[2] as
| { textStyles?: unknown[]; textMode?: string; accountId?: string | undefined }
| undefined;
expect(opts?.textMode).toBe("plain");
expect(opts?.accountId).toBeUndefined();
});
expect(sentTexts.join("")).toContain("Intro");
expect(sentTexts.join("")).toContain("Outro");
expect(sentTexts.join("")).toContain("y".repeat(20));
});
it("chunks WhatsApp text and returns all results", async () => {

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { parseTelegramTarget } from "../../../extensions/telegram/api.js";
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -17,24 +16,10 @@ import {
installResolveOutboundTargetPluginRegistryHooks,
runResolveOutboundTargetCoreTests,
} from "./targets.shared-test.js";
import { telegramMessagingForTest } from "./targets.test-helpers.js";
runResolveOutboundTargetCoreTests();
const telegramMessaging = {
parseExplicitTarget: ({ raw }: { raw: string }) => {
const target = parseTelegramTarget(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
inferTargetChatType: ({ to }: { to: string }) => {
const target = parseTelegramTarget(to);
return target.chatType === "unknown" ? undefined : target.chatType;
},
};
const whatsappMessaging = {
inferTargetChatType: ({ to }: { to: string }) => {
const normalized = normalizeWhatsAppTarget(to);
@@ -73,7 +58,7 @@ beforeEach(() => {
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: telegramOutbound,
messaging: telegramMessaging,
messaging: telegramMessagingForTest,
}),
source: "test",
},
@@ -156,7 +141,7 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: telegramOutbound,
messaging: telegramMessaging,
messaging: telegramMessagingForTest,
}),
source: "test",
});

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? "";
const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3";
const DEEPGRAM_BASE_URL = process.env.DEEPGRAM_BASE_URL?.trim();
const SAMPLE_URL =
process.env.DEEPGRAM_SAMPLE_URL?.trim() ||
"https://static.deepgram.com/examples/Bueller-Life-moves-pretty-fast.wav";
const LIVE = isLiveTestEnabled(["DEEPGRAM_LIVE_TEST"]);
const describeLive = LIVE && DEEPGRAM_KEY ? describe : describe.skip;
async function fetchSampleBuffer(url: string, timeoutMs: number): Promise<Buffer> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Sample download failed (HTTP ${res.status})`);
}
const data = await res.arrayBuffer();
return Buffer.from(data);
} finally {
clearTimeout(timer);
}
}
describeLive("deepgram live", () => {
it("transcribes sample audio", async () => {
const buffer = await fetchSampleBuffer(SAMPLE_URL, 15000);
const result = await transcribeDeepgramAudio({
buffer,
fileName: "sample.wav",
mime: "audio/wav",
apiKey: DEEPGRAM_KEY,
model: DEEPGRAM_MODEL,
baseUrl: DEEPGRAM_BASE_URL,
timeoutMs: 20000,
});
expect(result.text.trim().length).toBeGreaterThan(0);
}, 30000);
});

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
import {
createAuthCaptureJsonFetch,
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
installPinnedHostnameTestHooks();
describe("transcribeDeepgramAudio", () => {
it("respects lowercase authorization header overrides", async () => {
const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({
results: { channels: [{ alternatives: [{ transcript: "ok" }] }] },
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "test-key",
timeoutMs: 1000,
headers: { authorization: "Token override" },
fetchFn,
});
expect(getAuthHeader()).toBe("Token override");
expect(result.text).toBe("ok");
});
it("builds the expected request payload", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
results: { channels: [{ alternatives: [{ transcript: "hello" }] }] },
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
baseUrl: "https://api.example.com/v1/",
model: " ",
language: " en ",
mime: "audio/wav",
headers: { "X-Custom": "1" },
query: {
punctuate: false,
smart_format: true,
},
fetchFn,
});
const { url: seenUrl, init: seenInit } = getRequest();
expect(result.model).toBe("nova-3");
expect(result.text).toBe("hello");
expect(seenUrl).toBe(
"https://api.example.com/v1/listen?model=nova-3&language=en&punctuate=false&smart_format=true",
);
expect(seenInit?.method).toBe("POST");
expect(seenInit?.signal).toBeInstanceOf(AbortSignal);
const headers = new Headers(seenInit?.headers);
expect(headers.get("authorization")).toBe("Token test-key");
expect(headers.get("x-custom")).toBe("1");
expect(headers.get("content-type")).toBe("audio/wav");
expect(seenInit?.body).toBeInstanceOf(Uint8Array);
});
it("throws when the provider response omits transcript", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
results: { channels: [{ alternatives: [{}] }] },
});
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription response missing transcript");
});
});

View File

@@ -1,113 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describeGeminiVideo } from "../../extensions/google/media-understanding-provider.js";
import * as ssrf from "../infra/net/ssrf.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createRequestCaptureJsonFetch } from "./audio.test-helpers.js";
const TEST_NET_IP = "203.0.113.10";
function stubPinnedHostname(hostname: string) {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = [TEST_NET_IP];
return {
hostname: normalized,
addresses,
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
};
}
describe("describeGeminiVideo", () => {
let resolvePinnedHostnameWithPolicySpy: ReturnType<typeof vi.spyOn>;
let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Stub both entry points so fetch-guard never does live DNS (CI can use either path).
resolvePinnedHostnameWithPolicySpy = vi
.spyOn(ssrf, "resolvePinnedHostnameWithPolicy")
.mockImplementation(async (hostname) => stubPinnedHostname(hostname));
resolvePinnedHostnameSpy = vi
.spyOn(ssrf, "resolvePinnedHostname")
.mockImplementation(async (hostname) => stubPinnedHostname(hostname));
});
afterEach(() => {
resolvePinnedHostnameWithPolicySpy?.mockRestore();
resolvePinnedHostnameSpy?.mockRestore();
resolvePinnedHostnameWithPolicySpy = undefined;
resolvePinnedHostnameSpy = undefined;
});
it("respects case-insensitive x-goog-api-key overrides", async () => {
let seenKey: string | null = null;
const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenKey = headers.get("x-goog-api-key");
return new Response(
JSON.stringify({
candidates: [{ content: { parts: [{ text: "video ok" }] } }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
const result = await describeGeminiVideo({
buffer: Buffer.from("video"),
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1000,
headers: { "X-Goog-Api-Key": "override" },
fetchFn,
});
expect(seenKey).toBe("override");
expect(result.text).toBe("video ok");
});
it("builds the expected request payload", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
candidates: [
{
content: {
parts: [{ text: "first" }, { text: " second " }, { text: "" }],
},
},
],
});
const result = await describeGeminiVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1500,
baseUrl: "https://example.com/v1beta/",
model: "gemini-3-pro",
headers: { "X-Other": "1" },
fetchFn,
});
const { url: seenUrl, init: seenInit } = getRequest();
expect(result.model).toBe("gemini-3-pro-preview");
expect(result.text).toBe("first\nsecond");
expect(seenUrl).toBe("https://example.com/v1beta/models/gemini-3-pro-preview:generateContent");
expect(seenInit?.method).toBe("POST");
expect(seenInit?.signal).toBeInstanceOf(AbortSignal);
const headers = new Headers(seenInit?.headers);
expect(headers.get("x-goog-api-key")).toBe("test-key");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("x-other")).toBe("1");
const bodyText =
typeof seenInit?.body === "string"
? seenInit.body
: Buffer.isBuffer(seenInit?.body)
? seenInit.body.toString("utf8")
: "";
const body = JSON.parse(bodyText);
expect(body.contents?.[0]?.parts?.[0]?.text).toBe("Describe the video.");
expect(body.contents?.[0]?.parts?.[1]?.inline_data?.mime_type).toBe("video/mp4");
expect(body.contents?.[0]?.parts?.[1]?.inline_data?.data).toBe(
Buffer.from("video-bytes").toString("base64"),
);
});
});

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { mistralMediaUnderstandingProvider } from "../../extensions/mistral/media-understanding-provider.js";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
installPinnedHostnameTestHooks();
describe("mistralMediaUnderstandingProvider", () => {
it("has expected provider metadata", () => {
expect(mistralMediaUnderstandingProvider.id).toBe("mistral");
expect(mistralMediaUnderstandingProvider.capabilities).toEqual(["audio"]);
expect(mistralMediaUnderstandingProvider.transcribeAudio).toBeDefined();
});
it("uses Mistral base URL by default", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "bonjour" });
const result = await mistralMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.ogg",
apiKey: "test-mistral-key", // pragma: allowlist secret
timeoutMs: 5000,
fetchFn,
});
expect(getRequest().url).toBe("https://api.mistral.ai/v1/audio/transcriptions");
expect(result.text).toBe("bonjour");
});
it("allows overriding baseUrl", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "ok" });
await mistralMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "key", // pragma: allowlist secret
timeoutMs: 1000,
baseUrl: "https://custom.mistral.example/v1",
fetchFn,
});
expect(getRequest().url).toBe("https://custom.mistral.example/v1/audio/transcriptions");
});
});

View File

@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import { describeMoonshotVideo } from "../../extensions/moonshot/media-understanding-provider.js";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
installPinnedHostnameTestHooks();
describe("describeMoonshotVideo", () => {
it("builds an OpenAI-compatible video request", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
choices: [{ message: { content: "video ok" } }],
});
const result = await describeMoonshotVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "moonshot-test", // pragma: allowlist secret
timeoutMs: 1500,
baseUrl: "https://api.moonshot.ai/v1/",
model: "kimi-k2.5",
headers: { "X-Trace": "1" },
fetchFn,
});
const { url, init } = getRequest();
expect(result.text).toBe("video ok");
expect(result.model).toBe("kimi-k2.5");
expect(url).toBe("https://api.moonshot.ai/v1/chat/completions");
expect(init?.method).toBe("POST");
expect(init?.signal).toBeInstanceOf(AbortSignal);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer moonshot-test");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("x-trace")).toBe("1");
const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as {
model?: string;
messages?: Array<{
content?: Array<{ type?: string; text?: string; video_url?: { url?: string } }>;
}>;
};
expect(body.model).toBe("kimi-k2.5");
expect(body.messages?.[0]?.content?.[0]).toMatchObject({
type: "text",
text: "Describe the video.",
});
expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url");
expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe(
`data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`,
);
});
it("falls back to reasoning_content when content is empty", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
choices: [{ message: { content: "", reasoning_content: "reasoned answer" } }],
});
const result = await describeMoonshotVideo({
buffer: Buffer.from("video"),
fileName: "clip.mp4",
apiKey: "moonshot-test", // pragma: allowlist secret
timeoutMs: 1000,
fetchFn,
});
expect(result.text).toBe("reasoned answer");
expect(result.model).toBe("kimi-k2.5");
});
});

View File

@@ -1,84 +0,0 @@
import { describe, expect, it } from "vitest";
import { transcribeOpenAiAudio } from "../../extensions/openai/media-understanding-provider.js";
import {
createAuthCaptureJsonFetch,
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
installPinnedHostnameTestHooks();
describe("transcribeOpenAiAudio", () => {
it("respects lowercase authorization header overrides", async () => {
const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({ text: "ok" });
const result = await transcribeOpenAiAudio({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "test-key",
timeoutMs: 1000,
headers: { authorization: "Bearer override" },
fetchFn,
});
expect(getAuthHeader()).toBe("Bearer override");
expect(result.text).toBe("ok");
});
it("builds the expected request payload", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "hello" });
const result = await transcribeOpenAiAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
baseUrl: "https://api.example.com/v1/",
model: " ",
language: " en ",
prompt: " hello ",
mime: "audio/wav",
headers: { "X-Custom": "1" },
fetchFn,
});
const { url: seenUrl, init: seenInit } = getRequest();
expect(result.model).toBe("gpt-4o-mini-transcribe");
expect(result.text).toBe("hello");
expect(seenUrl).toBe("https://api.example.com/v1/audio/transcriptions");
expect(seenInit?.method).toBe("POST");
expect(seenInit?.signal).toBeInstanceOf(AbortSignal);
const headers = new Headers(seenInit?.headers);
expect(headers.get("authorization")).toBe("Bearer test-key");
expect(headers.get("x-custom")).toBe("1");
const form = seenInit?.body as FormData;
expect(form).toBeInstanceOf(FormData);
expect(form.get("model")).toBe("gpt-4o-mini-transcribe");
expect(form.get("language")).toBe("en");
expect(form.get("prompt")).toBe("hello");
const file = form.get("file") as Blob | { type?: string; name?: string } | null;
expect(file).not.toBeNull();
if (file) {
expect(file.type).toBe("audio/wav");
if ("name" in file && typeof file.name === "string") {
expect(file.name).toBe("voice.wav");
}
}
});
it("throws when the provider response omits text", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({});
await expect(
transcribeOpenAiAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription response missing text");
});
});

View File

@@ -1,348 +0,0 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const undiciMocks = vi.hoisted(() => {
const createDispatcherCtor = <T extends Record<string, unknown> | string>() =>
vi.fn(function MockDispatcher(this: { options?: T }, options?: T) {
this.options = options;
});
return {
fetch: vi.fn(),
agentCtor: createDispatcherCtor<Record<string, unknown>>(),
envHttpProxyAgentCtor: createDispatcherCtor<Record<string, unknown>>(),
proxyAgentCtor: createDispatcherCtor<Record<string, unknown> | string>(),
};
});
vi.mock("undici", () => ({
Agent: undiciMocks.agentCtor,
EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor,
ProxyAgent: undiciMocks.proxyAgentCtor,
fetch: undiciMocks.fetch,
}));
let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia;
let resolveTelegramTransport: typeof import("../../extensions/telegram/runtime-api.js").resolveTelegramTransport;
let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/runtime-api.js").shouldRetryTelegramTransportFallback;
let makeProxyFetch: typeof import("../../extensions/telegram/runtime-api.js").makeProxyFetch;
let TEST_UNDICI_RUNTIME_DEPS_KEY: typeof import("../infra/net/undici-runtime.js").TEST_UNDICI_RUNTIME_DEPS_KEY;
describe("fetchRemoteMedia telegram network policy", () => {
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
beforeAll(async () => {
vi.resetModules();
({ TEST_UNDICI_RUNTIME_DEPS_KEY } = await import("../infra/net/undici-runtime.js"));
({ fetchRemoteMedia } = await import("./fetch.js"));
({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } =
await import("../../extensions/telegram/runtime-api.js"));
({ makeProxyFetch } = await import("../../extensions/telegram/runtime-api.js"));
});
beforeEach(() => {
undiciMocks.fetch.mockReset();
undiciMocks.agentCtor.mockClear();
undiciMocks.envHttpProxyAgentCtor.mockClear();
undiciMocks.proxyAgentCtor.mockClear();
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: undiciMocks.agentCtor,
EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor,
ProxyAgent: undiciMocks.proxyAgentCtor,
};
});
function createTelegramFetchFailedError(code: string): Error {
return Object.assign(new TypeError("fetch failed"), {
cause: { code },
});
}
afterEach(() => {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
vi.unstubAllEnvs();
});
afterAll(() => {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
});
it("preserves Telegram resolver transport policy for file downloads", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
]) as unknown as LookupFn;
undiciMocks.fetch.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "verbatim",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/1.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const init = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
expect(init?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
);
});
it("keeps explicit proxy routing for file downloads", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
]) as unknown as LookupFn;
undiciMocks.fetch.mockResolvedValueOnce(
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
);
const telegramTransport = resolveTelegramTransport(makeProxyFetch("http://127.0.0.1:7890"), {
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/files/1.pdf",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const init = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
uri?: string;
requestTls?: Record<string, unknown>;
};
};
})
| undefined;
expect(init?.dispatcher?.options?.uri).toBe("http://127.0.0.1:7890");
expect(init?.dispatcher?.options?.requestTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
expect(undiciMocks.proxyAgentCtor).toHaveBeenCalled();
});
it("retries Telegram file downloads with IPv4 fallback when the first fetch fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
undiciMocks.fetch
.mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH"))
.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/2.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const firstInit = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
const secondInit = undiciMocks.fetch.mock.calls[1]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
expect(undiciMocks.fetch).toHaveBeenCalledTimes(2);
expect(firstInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
);
expect(secondInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
});
it("retries Telegram file downloads with pinned Telegram IP after IPv4 fallback fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.221", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
undiciMocks.fetch
.mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH"))
.mockRejectedValueOnce(createTelegramFetchFailedError("ETIMEDOUT"))
.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/3.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const thirdInit = undiciMocks.fetch.mock.calls[2]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
const callback = vi.fn();
(
thirdInit?.dispatcher?.options?.connect?.lookup as
| ((
hostname: string,
callback: (err: null, address: string, family: number) => void,
) => void)
| undefined
)?.("api.telegram.org", callback);
expect(undiciMocks.fetch).toHaveBeenCalledTimes(3);
expect(thirdInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4);
});
it("preserves both primary and final fallback errors when Telegram media retry chain fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
const primaryError = createTelegramFetchFailedError("EHOSTUNREACH");
const ipv4Error = createTelegramFetchFailedError("ETIMEDOUT");
const fallbackError = createTelegramFetchFailedError("ETIMEDOUT");
undiciMocks.fetch
.mockRejectedValueOnce(primaryError)
.mockRejectedValueOnce(ipv4Error)
.mockRejectedValueOnce(fallbackError);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await expect(
fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/4.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
}),
).rejects.toMatchObject({
name: "MediaFetchError",
code: "fetch_failed",
cause: expect.objectContaining({
name: "Error",
cause: fallbackError,
attemptErrors: [primaryError, ipv4Error, fallbackError],
primaryError,
}),
});
});
});

View File

@@ -1,18 +1,8 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildElevenLabsSpeechProvider,
isValidVoiceId,
} from "../../extensions/elevenlabs/speech-provider.ts";
import { buildMicrosoftSpeechProvider } from "../../extensions/microsoft/speech-provider.ts";
import { buildOpenAISpeechProvider } from "../../extensions/openai/speech-provider.ts";
import {
isValidOpenAIModel,
isValidOpenAIVoice,
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
resolveOpenAITtsInstructions,
} from "../../extensions/openai/tts.ts";
import { buildElevenLabsSpeechProvider } from "../../extensions/elevenlabs/test-api.js";
import { buildMicrosoftSpeechProvider } from "../../extensions/microsoft/test-api.js";
import { buildOpenAISpeechProvider } from "../../extensions/openai/test-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
@@ -157,94 +147,6 @@ describe("tts", () => {
);
});
describe("isValidVoiceId", () => {
it("validates ElevenLabs voice ID length and character rules", () => {
const cases = [
{ value: "pMsXgVXv3BLzUgSXRplE", expected: true },
{ value: "21m00Tcm4TlvDq8ikWAM", expected: true },
{ value: "EXAVITQu4vr4xnSDxMaL", expected: true },
{ value: "a1b2c3d4e5", expected: true },
{ value: "a".repeat(40), expected: true },
{ value: "", expected: false },
{ value: "abc", expected: false },
{ value: "123456789", expected: false },
{ value: "a".repeat(41), expected: false },
{ value: "a".repeat(100), expected: false },
{ value: "pMsXgVXv3BLz-gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz_gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz gSXRplE", expected: false },
{ value: "../../../etc/passwd", expected: false },
{ value: "voice?param=value", expected: false },
] as const;
for (const testCase of cases) {
expect(isValidVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
}
});
});
describe("isValidOpenAIVoice", () => {
it("accepts all valid OpenAI voices including newer additions", () => {
for (const voice of OPENAI_TTS_VOICES) {
expect(isValidOpenAIVoice(voice)).toBe(true);
}
for (const newerVoice of ["ballad", "cedar", "juniper", "marin", "verse"]) {
expect(isValidOpenAIVoice(newerVoice), newerVoice).toBe(true);
}
});
it("rejects invalid voice names", () => {
expect(isValidOpenAIVoice("invalid")).toBe(false);
expect(isValidOpenAIVoice("")).toBe(false);
expect(isValidOpenAIVoice("ALLOY")).toBe(false);
expect(isValidOpenAIVoice("alloy ")).toBe(false);
expect(isValidOpenAIVoice(" alloy")).toBe(false);
});
it("treats the default endpoint with trailing slash as the default endpoint", () => {
expect(isValidOpenAIVoice("kokoro-custom-voice", "https://api.openai.com/v1/")).toBe(false);
});
});
describe("isValidOpenAIModel", () => {
it("matches the supported model set and rejects unsupported values", () => {
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
expect(OPENAI_TTS_MODELS).toContain("tts-1");
expect(OPENAI_TTS_MODELS).toContain("tts-1-hd");
expect(OPENAI_TTS_MODELS).toHaveLength(3);
expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true);
expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0);
const cases = [
{ model: "gpt-4o-mini-tts", expected: true },
{ model: "tts-1", expected: true },
{ model: "tts-1-hd", expected: true },
{ model: "invalid", expected: false },
{ model: "", expected: false },
{ model: "gpt-4", expected: false },
] as const;
for (const testCase of cases) {
expect(isValidOpenAIModel(testCase.model), testCase.model).toBe(testCase.expected);
}
});
it("treats the default endpoint with trailing slash as the default endpoint", () => {
expect(isValidOpenAIModel("kokoro-custom-model", "https://api.openai.com/v1/")).toBe(false);
});
});
describe("resolveOpenAITtsInstructions", () => {
it("keeps instructions only for gpt-4o-mini-tts variants", () => {
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts", " Speak warmly ")).toBe(
"Speak warmly",
);
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts-2025-12-15", "Speak warmly")).toBe(
"Speak warmly",
);
expect(resolveOpenAITtsInstructions("tts-1", "Speak warmly")).toBeUndefined();
expect(resolveOpenAITtsInstructions("tts-1-hd", "Speak warmly")).toBeUndefined();
expect(resolveOpenAITtsInstructions("gpt-4o-mini-tts", " ")).toBeUndefined();
});
});
describe("resolveEdgeOutputFormat", () => {
const baseCfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },