mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 02:12:07 +00:00
test: move extension-owned coverage into plugins
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
|
||||
27
extensions/elevenlabs/speech-provider.test.ts
Normal file
27
extensions/elevenlabs/speech-provider.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
1
extensions/elevenlabs/test-api.ts
Normal file
1
extensions/elevenlabs/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { buildElevenLabsSpeechProvider } from "./speech-provider.js";
|
||||
@@ -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));
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
26
extensions/litellm/onboard.test.ts
Normal file
26
extensions/litellm/onboard.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
1
extensions/microsoft/test-api.ts
Normal file
1
extensions/microsoft/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { buildMicrosoftSpeechProvider } from "./speech-provider.js";
|
||||
123
extensions/minimax/onboard.test.ts
Normal file
123
extensions/minimax/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
77
extensions/mistral/onboard.test.ts
Normal file
77
extensions/mistral/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
1
extensions/openai/test-api.ts
Normal file
1
extensions/openai/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { buildOpenAISpeechProvider } from "./speech-provider.js";
|
||||
73
extensions/openai/tts.test.ts
Normal file
73
extensions/openai/tts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
40
extensions/opencode-go/onboard.test.ts
Normal file
40
extensions/opencode-go/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
extensions/opencode/onboard.test.ts
Normal file
40
extensions/opencode/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
48
extensions/openrouter/onboard.test.ts
Normal file
48
extensions/openrouter/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1
extensions/perplexity/test-api.ts
Normal file
1
extensions/perplexity/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { __testing } from "./src/perplexity-web-search-provider.js";
|
||||
@@ -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", () => {
|
||||
|
||||
1
extensions/signal/test-api.ts
Normal file
1
extensions/signal/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { signalOutbound } from "./src/outbound-adapter.js";
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
37
extensions/synthetic/onboard.test.ts
Normal file
37
extensions/synthetic/onboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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";
|
||||
|
||||
58
extensions/xai/onboard.test.ts
Normal file
58
extensions/xai/onboard.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
60
extensions/xai/src/grok-web-search-provider.test.ts
Normal file
60
extensions/xai/src/grok-web-search-provider.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
41
extensions/xiaomi/onboard.test.ts
Normal file
41
extensions/xiaomi/onboard.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
extensions/zai/onboard.test.ts
Normal file
40
extensions/zai/onboard.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -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";
|
||||
|
||||
46
test/helpers/extensions/onboard-config.ts
Normal file
46
test/helpers/extensions/onboard-config.ts
Normal 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] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user