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

@@ -16,6 +16,18 @@ describe("brave web search provider", () => {
search_lang: "jp",
ui_lang: "en-US",
});
expect(__testing.normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual(
{
search_lang: "tr",
ui_lang: "tr-TR",
},
);
expect(__testing.normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual(
{
search_lang: "en",
ui_lang: "en-US",
},
);
});
it("flags invalid brave language fields", () => {
@@ -24,6 +36,12 @@ describe("brave web search provider", () => {
search_lang: "xx",
}),
).toEqual({ invalidField: "search_lang" });
expect(__testing.normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(__testing.normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
it("defaults brave mode to web unless llm-context is explicitly selected", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
import { transcribeDeepgramAudio } from "./audio.js";
const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? "";
const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3";

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { transcribeDeepgramAudio } from "../../extensions/deepgram/audio.js";
import {
createAuthCaptureJsonFetch,
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
} from "../../src/media-understanding/audio.test-helpers.js";
import { transcribeDeepgramAudio } from "./audio.js";
installPinnedHostnameTestHooks();

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isValidVoiceId } from "./speech-provider.js";
describe("elevenlabs speech provider", () => {
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);
}
});
});

View File

@@ -0,0 +1 @@
export { buildElevenLabsSpeechProvider } from "./speech-provider.js";

View File

@@ -1,8 +1,8 @@
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";
import * as ssrf from "../../src/infra/net/ssrf.js";
import { createRequestCaptureJsonFetch } from "../../src/media-understanding/audio.test-helpers.js";
import { withFetchPreconnect } from "../../src/test-utils/fetch-mock.js";
import { describeGeminiVideo } from "./media-understanding-provider.js";
const TEST_NET_IP = "203.0.113.10";
@@ -21,7 +21,6 @@ describe("describeGeminiVideo", () => {
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));

View File

@@ -23,5 +23,6 @@ export {
export { monitorIMessageProvider } from "./src/monitor.js";
export type { MonitorIMessageOpts } from "./src/monitor.js";
export { __testing as imessageMonitorTesting } from "./src/monitor/monitor-provider.js";
export { imessageOutbound } from "./src/outbound-adapter.js";
export { probeIMessage } from "./src/probe.js";
export { sendMessageIMessage } from "./src/send.js";

View File

@@ -2,23 +2,23 @@ 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 { resolveApiKeyForProvider, resolveEnvApiKey } from "../../src/agents/model-auth.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import { resolveAgentModelPrimaryValue } from "../../src/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";
} from "../../src/plugin-sdk/provider-models.js";
import { captureEnv } from "../../src/test-utils/env.js";
import {
applyKilocodeProviderConfig,
applyKilocodeConfig,
KILOCODE_BASE_URL,
KILOCODE_DEFAULT_MODEL_REF,
} from "./onboard.js";
const emptyCfg: OpenClawConfig = {};
const KILOCODE_MODEL_IDS = ["kilo/auto"];
@@ -150,7 +150,7 @@ describe("Kilo Gateway provider config", () => {
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
process.env.KILOCODE_API_KEY = "test-kilo-key";
try {
const result = resolveEnvApiKey("kilocode");
@@ -177,7 +177,7 @@ describe("Kilo Gateway provider config", () => {
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
process.env.KILOCODE_API_KEY = "kilo-provider-test-key";
try {
const auth = await resolveApiKeyForProvider({

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createLegacyProviderConfig } from "../../test/helpers/extensions/onboard-config.js";
import { applyLitellmProviderConfig } from "./onboard.js";
describe("litellm onboard", () => {
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",
]);
});
});

View File

@@ -0,0 +1 @@
export { buildMicrosoftSpeechProvider } from "./speech-provider.js";

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import {
createConfigWithFallbacks,
createLegacyProviderConfig,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import { applyMinimaxApiConfig, applyMinimaxApiProviderConfig } from "./onboard.js";
describe("minimax onboard", () => {
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",
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");
});
it("does not overwrite existing primary model in provider-only mode", () => {
const cfg = applyMinimaxApiProviderConfig({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
"anthropic/claude-opus-4-5",
);
});
it("sets the chosen model as primary in config mode", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed");
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
"minimax/MiniMax-M2.7-highspeed",
);
});
it("preserves existing model fallbacks", () => {
const cfg = applyMinimaxApiConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { mistralMediaUnderstandingProvider } from "../../extensions/mistral/media-understanding-provider.js";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
} from "../../src/media-understanding/audio.test-helpers.js";
import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
@@ -20,7 +20,7 @@ describe("mistralMediaUnderstandingProvider", () => {
const result = await mistralMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.ogg",
apiKey: "test-mistral-key", // pragma: allowlist secret
apiKey: "test-mistral-key",
timeoutMs: 5000,
fetchFn,
});
@@ -35,7 +35,7 @@ describe("mistralMediaUnderstandingProvider", () => {
await mistralMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "key", // pragma: allowlist secret
apiKey: "key",
timeoutMs: 1000,
baseUrl: "https://custom.mistral.example/v1",
fetchFn,

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import { buildMistralModelDefinition as buildCoreMistralModelDefinition } from "../../src/plugins/provider-model-definitions.js";
import {
createConfigWithFallbacks,
createLegacyProviderConfig,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import { buildMistralModelDefinition as buildBundledMistralModelDefinition } from "./model-definitions.js";
import {
applyMistralConfig,
applyMistralProviderConfig,
MISTRAL_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("mistral onboard", () => {
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,
);
});
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,
});
});
it("adds the expected alias for the default model", () => {
const cfg = applyMistralProviderConfig({});
expect(cfg.agents?.defaults?.models?.[MISTRAL_DEFAULT_MODEL_REF]?.alias).toBe("Mistral");
});
it("preserves existing model fallbacks", () => {
const cfg = applyMistralConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { describeMoonshotVideo } from "../../extensions/moonshot/media-understanding-provider.js";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
} from "../../src/media-understanding/audio.test-helpers.js";
import { describeMoonshotVideo } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
@@ -16,7 +16,7 @@ describe("describeMoonshotVideo", () => {
const result = await describeMoonshotVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "moonshot-test", // pragma: allowlist secret
apiKey: "moonshot-test",
timeoutMs: 1500,
baseUrl: "https://api.moonshot.ai/v1/",
model: "kimi-k2.5",
@@ -61,7 +61,7 @@ describe("describeMoonshotVideo", () => {
const result = await describeMoonshotVideo({
buffer: Buffer.from("video"),
fileName: "clip.mp4",
apiKey: "moonshot-test", // pragma: allowlist secret
apiKey: "moonshot-test",
timeoutMs: 1000,
fetchFn,
});

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../../src/test-utils/env.js";
import { __testing } from "./kimi-web-search-provider.js";
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
describe("kimi web search provider", () => {
it("uses configured model and base url overrides with sane defaults", () => {
expect(__testing.resolveKimiModel()).toBe("moonshot-v1-128k");
@@ -34,4 +37,14 @@ describe("kimi web search provider", () => {
}),
).toEqual(["https://a.test", "https://b.test", "https://c.test"]);
});
it("uses config apiKey when provided", () => {
expect(__testing.resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ [kimiApiKeyEnv]: "kimi-env-key" }, () => {
expect(__testing.resolveKimiApiKey({})).toBe("kimi-env-key");
});
});
});

View File

@@ -1,12 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
import {
enrichOllamaModelsWithContext,
resolveOllamaApiBase,
type OllamaTagModel,
} from "../../extensions/ollama/api.js";
import { jsonResponse, requestBodyText, requestUrl } from "../test-helpers/http.js";
} from "./provider-models.js";
describe("ollama-models", () => {
describe("ollama provider models", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

View File

@@ -1,15 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../src/runtime.js";
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
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";
} from "./setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../agents/auth-profiles.js", () => ({
vi.mock("../../../src/agents/auth-profiles.js", () => ({
upsertAuthProfileWithLock,
}));

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { transcribeOpenAiAudio } from "../../extensions/openai/media-understanding-provider.js";
import {
createAuthCaptureJsonFetch,
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "./audio.test-helpers.js";
} from "../../src/media-understanding/audio.test-helpers.js";
import { transcribeOpenAiAudio } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();

View File

@@ -0,0 +1 @@
export { buildOpenAISpeechProvider } from "./speech-provider.js";

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
isValidOpenAIModel,
isValidOpenAIVoice,
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
resolveOpenAITtsInstructions,
} from "./tts.js";
describe("openai tts", () => {
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();
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import {
createConfigWithFallbacks,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import { applyOpencodeGoConfig, applyOpencodeGoProviderConfig } from "./onboard.js";
const MODEL_REF = "opencode-go/kimi-k2.5";
describe("opencode-go onboard", () => {
it("adds allowlist entry and preserves alias", () => {
const withDefault = applyOpencodeGoProviderConfig({});
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(MODEL_REF);
const withAlias = applyOpencodeGoProviderConfig({
agents: {
defaults: {
models: {
[MODEL_REF]: { alias: "Kimi" },
},
},
},
});
expect(withAlias.agents?.defaults?.models?.[MODEL_REF]?.alias).toBe("Kimi");
});
it("sets primary model and preserves existing model fallbacks", () => {
const cfg = applyOpencodeGoConfig({});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(MODEL_REF);
const cfgWithFallbacks = applyOpencodeGoConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import {
createConfigWithFallbacks,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import { applyOpencodeZenConfig, applyOpencodeZenProviderConfig } from "./onboard.js";
const MODEL_REF = "opencode/claude-opus-4-6";
describe("opencode onboard", () => {
it("adds allowlist entry and preserves alias", () => {
const withDefault = applyOpencodeZenProviderConfig({});
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(MODEL_REF);
const withAlias = applyOpencodeZenProviderConfig({
agents: {
defaults: {
models: {
[MODEL_REF]: { alias: "My Opus" },
},
},
},
});
expect(withAlias.agents?.defaults?.models?.[MODEL_REF]?.alias).toBe("My Opus");
});
it("sets primary model and preserves existing model fallbacks", () => {
const cfg = applyOpencodeZenConfig({});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(MODEL_REF);
const cfgWithFallbacks = applyOpencodeZenConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import {
createConfigWithFallbacks,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("openrouter onboard", () => {
it("adds allowlist entry and preserves alias", () => {
const withDefault = applyOpenrouterProviderConfig({});
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(
OPENROUTER_DEFAULT_MODEL_REF,
);
const withAlias = applyOpenrouterProviderConfig({
agents: {
defaults: {
models: {
[OPENROUTER_DEFAULT_MODEL_REF]: { alias: "Router" },
},
},
},
});
expect(withAlias.agents?.defaults?.models?.[OPENROUTER_DEFAULT_MODEL_REF]?.alias).toBe(
"Router",
);
});
it("sets primary model and preserves existing model fallbacks", () => {
const cfg = applyOpenrouterConfig({});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
OPENROUTER_DEFAULT_MODEL_REF,
);
const cfgWithFallbacks = applyOpenrouterConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -4,14 +4,14 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createOpenShellSandboxBackendFactory } from "../extensions/openshell/src/backend.js";
import { resolveOpenShellPluginConfig } from "../extensions/openshell/src/config.js";
import { createSandboxTestContext } from "../src/agents/sandbox/test-fixtures.js";
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
} from "./helpers/sandbox-fixtures.js";
} from "../../../test/helpers/sandbox-fixtures.js";
import { createOpenShellSandboxBackendFactory } from "./backend.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const OPENCLAW_OPENSHELL_E2E = process.env.OPENCLAW_E2E_OPENSHELL === "1";
const OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS = 12 * 60_000;

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../../src/test-utils/env.js";
import { __testing } from "./perplexity-web-search-provider.js";
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("perplexity web search provider", () => {
it("infers provider routing from api key prefixes", () => {
expect(__testing.inferPerplexityBaseUrlFromApiKey("pplx-abc")).toBe("direct");
@@ -39,4 +46,67 @@ describe("perplexity web search provider", () => {
}).transport,
).toBe("search_api");
});
it("prefers explicit baseUrl over key-based defaults", () => {
expect(
__testing.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(__testing.resolvePerplexityApiKey(undefined)).toEqual({
apiKey: openRouterPerplexityApiKey,
source: "openrouter_env",
});
expect(__testing.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(__testing.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(__testing.resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
"perplexity/sonar-reasoning-pro",
);
expect(
__testing.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(
__testing.resolvePerplexityTransport({
apiKey: enterprisePerplexityApiKey,
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
transport: "search_api",
});
});
});

View File

@@ -0,0 +1 @@
export { __testing } from "./src/perplexity-web-search-provider.js";

View File

@@ -114,12 +114,31 @@ describe("classifySignalCliLogLine", () => {
});
describe("signal setup parsing", () => {
it("accepts already normalized numbers", () => {
expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123");
});
it("normalizes valid E.164 numbers", () => {
expect(normalizeSignalAccountInput(" +1 (555) 555-0123 ")).toBe("+15555550123");
});
it("rejects empty input", () => {
expect(normalizeSignalAccountInput(" ")).toBeNull();
});
it("rejects invalid values", () => {
expect(normalizeSignalAccountInput("abc")).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();
});
it("parses e164, uuid and wildcard entries", () => {

View File

@@ -0,0 +1 @@
export { signalOutbound } from "./src/outbound-adapter.js";

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
vi.mock("../../../../extensions/slack/test-api.js", () => ({
vi.mock("./send.js", () => ({
sendMessageSlack: vi.fn().mockResolvedValue({ messageId: "1234.5678", channelId: "C123" }),
}));
@@ -10,8 +10,8 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
}));
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { sendMessageSlack } from "../../../../extensions/slack/test-api.js";
import { slackOutbound } from "../../../../test/channel-outbounds.js";
import { slackOutbound } from "./outbound-adapter.js";
import { sendMessageSlack } from "./send.js";
type SlackSendTextCtx = {
to: string;
@@ -118,8 +118,7 @@ describe("slack outbound hook wiring", () => {
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);
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as never);
await sendSlackTextWithDefaults({ text: "hello" });
@@ -136,8 +135,7 @@ describe("slack outbound hook wiring", () => {
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);
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as never);
const result = await sendSlackTextWithDefaults({ text: "hello" });
@@ -150,8 +148,7 @@ describe("slack outbound hook wiring", () => {
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);
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as never);
await sendSlackTextWithDefaults({ text: "original" });
expectSlackSendCalledWith("modified");
@@ -162,8 +159,7 @@ describe("slack outbound hook wiring", () => {
hasHooks: vi.fn().mockReturnValue(false),
runMessageSending: vi.fn(),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as never);
await sendSlackTextWithDefaults({ text: "hello" });

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { createSlackOutboundPayloadHarness } from "../contracts/suites.js";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/suites.js";
function createHarness(params: {
payload: ReplyPayload;

View File

@@ -3,4 +3,5 @@ export type { SlackMessageEvent } from "./src/types.js";
export { createSlackActions } from "./src/channel-actions.js";
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";
export { slackOutbound } from "./src/outbound-adapter.js";
export { sendMessageSlack } from "./src/send.js";

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { SYNTHETIC_DEFAULT_MODEL_ID } from "../../src/agents/synthetic-models.js";
import { resolveAgentModelPrimaryValue } from "../../src/config/model-input.js";
import { createLegacyProviderConfig } from "../../test/helpers/extensions/onboard-config.js";
import {
applySyntheticConfig,
applySyntheticProviderConfig,
SYNTHETIC_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("synthetic onboard", () => {
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",
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
SYNTHETIC_DEFAULT_MODEL_REF,
);
});
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);
});
});

View File

@@ -61,7 +61,11 @@ export {
export { telegramMessageActions } from "./src/channel-actions.js";
export { monitorTelegramProvider } from "./src/monitor.js";
export { probeTelegram } from "./src/probe.js";
export { resolveTelegramTransport, shouldRetryTelegramTransportFallback } from "./src/fetch.js";
export {
resolveTelegramFetch,
resolveTelegramTransport,
shouldRetryTelegramTransportFallback,
} from "./src/fetch.js";
export { makeProxyFetch } from "./src/proxy.js";
export {
createForumTopicTelegram,

View File

@@ -21,22 +21,22 @@ vi.mock("undici", () => ({
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;
let fetchRemoteMedia: typeof import("../../../src/media/fetch.js").fetchRemoteMedia;
let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport;
let shouldRetryTelegramTransportFallback: typeof import("./fetch.js").shouldRetryTelegramTransportFallback;
let makeProxyFetch: typeof import("./proxy.js").makeProxyFetch;
let TEST_UNDICI_RUNTIME_DEPS_KEY: typeof import("../../../src/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"));
({ TEST_UNDICI_RUNTIME_DEPS_KEY } = await import("../../../src/infra/net/undici-runtime.js"));
({ fetchRemoteMedia } = await import("../../../src/media/fetch.js"));
({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } =
await import("../../extensions/telegram/runtime-api.js"));
({ makeProxyFetch } = await import("../../extensions/telegram/runtime-api.js"));
await import("./fetch.js"));
({ makeProxyFetch } = await import("./proxy.js"));
});
beforeEach(() => {

View File

@@ -1,4 +1,8 @@
export { buildTelegramMessageContextForTest } from "./src/bot-message-context.test-harness.js";
export { handleTelegramAction } from "./src/action-runtime.js";
export { telegramMessageActionRuntime } from "./src/channel-actions.js";
export { listTelegramAccountIds, resolveTelegramAccount } from "./src/accounts.js";
export { resolveTelegramFetch } from "./src/fetch.js";
export { makeProxyFetch } from "./src/proxy.js";
export { telegramOutbound } from "./src/outbound-adapter.js";
export { sendMessageTelegram, sendPollTelegram, type TelegramApiOverride } from "./src/send.js";

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../../src/config/model-input.js";
import {
createConfigWithFallbacks,
createLegacyProviderConfig,
EXPECTED_FALLBACKS,
} from "../../test/helpers/extensions/onboard-config.js";
import { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
describe("xai onboard", () => {
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);
});
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",
]),
);
});
it("adds expected alias for the default model", () => {
const cfg = applyXaiProviderConfig({});
expect(cfg.agents?.defaults?.models?.[XAI_DEFAULT_MODEL_REF]?.alias).toBe("Grok");
});
it("preserves existing model fallbacks", () => {
const cfg = applyXaiConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../../src/test-utils/env.js";
import { __testing } from "./grok-web-search-provider.js";
describe("grok web search provider", () => {
it("uses config apiKey when provided", () => {
expect(__testing.resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ XAI_API_KEY: "xai-env-key" }, () => {
expect(__testing.resolveGrokApiKey({})).toBe("xai-env-key");
});
});
it("uses config model when provided", () => {
expect(__testing.resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast");
});
it("normalizes deprecated grok 4.20 beta ids to GA ids", () => {
expect(
__testing.resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" }),
).toBe("grok-4.20-beta-latest-reasoning");
expect(
__testing.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(__testing.resolveGrokModel({})).toBe("grok-4-1-fast");
});
it("resolves inline citations flag", () => {
expect(__testing.resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(__testing.resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
expect(__testing.resolveGrokInlineCitations({})).toBe(false);
});
it("extracts content and annotation citations", () => {
expect(
__testing.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

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveAgentModelPrimaryValue } from "../../src/config/model-input.js";
import { createLegacyProviderConfig } from "../../test/helpers/extensions/onboard-config.js";
import { applyXiaomiConfig, applyXiaomiProviderConfig } from "./onboard.js";
describe("xiaomi onboard", () => {
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",
]);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveAgentModelPrimaryValue } from "../../src/config/model-input.js";
import {
ZAI_CODING_CN_BASE_URL,
ZAI_GLOBAL_BASE_URL,
} from "../../src/plugins/provider-model-definitions.js";
import { applyZaiConfig, applyZaiProviderConfig } from "./onboard.js";
describe("zai onboard", () => {
it("adds zai provider with correct settings", () => {
const cfg = applyZaiConfig({});
expect(cfg.models?.providers?.zai).toMatchObject({
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}`);
}
});
it("does not overwrite existing primary model in provider-only mode", () => {
const cfg = applyZaiProviderConfig({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
});
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
"anthropic/claude-opus-4-5",
);
});
});

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

@@ -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,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

@@ -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,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" } } },

View File

@@ -1,6 +1,6 @@
export { discordOutbound } from "../extensions/discord/src/outbound-adapter.js";
export { imessageOutbound } from "../extensions/imessage/src/outbound-adapter.js";
export { signalOutbound } from "../extensions/signal/src/outbound-adapter.js";
export { slackOutbound } from "../extensions/slack/src/outbound-adapter.js";
export { telegramOutbound } from "../extensions/telegram/src/outbound-adapter.js";
export { whatsappOutbound } from "../extensions/whatsapp/src/outbound-adapter.js";
export { discordOutbound } from "../extensions/discord/test-api.js";
export { imessageOutbound } from "../extensions/imessage/runtime-api.js";
export { signalOutbound } from "../extensions/signal/test-api.js";
export { slackOutbound } from "../extensions/slack/test-api.js";
export { telegramOutbound } from "../extensions/telegram/test-api.js";
export { whatsappOutbound } from "../extensions/whatsapp/test-api.js";

View File

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