diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index 187a63ec38c..79f8fe684b9 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -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", () => { diff --git a/src/media-understanding/deepgram.audio.live.test.ts b/extensions/deepgram/audio.live.test.ts similarity index 90% rename from src/media-understanding/deepgram.audio.live.test.ts rename to extensions/deepgram/audio.live.test.ts index c451672392d..bdfba2fca5e 100644 --- a/src/media-understanding/deepgram.audio.live.test.ts +++ b/extensions/deepgram/audio.live.test.ts @@ -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"; diff --git a/src/media-understanding/deepgram.audio.test.ts b/extensions/deepgram/audio.test.ts similarity index 95% rename from src/media-understanding/deepgram.audio.test.ts rename to extensions/deepgram/audio.test.ts index 4cb4a1387f9..9367eef2c43 100644 --- a/src/media-understanding/deepgram.audio.test.ts +++ b/extensions/deepgram/audio.test.ts @@ -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(); diff --git a/extensions/elevenlabs/speech-provider.test.ts b/extensions/elevenlabs/speech-provider.test.ts new file mode 100644 index 00000000000..512e060133f --- /dev/null +++ b/extensions/elevenlabs/speech-provider.test.ts @@ -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); + } + }); +}); diff --git a/extensions/elevenlabs/test-api.ts b/extensions/elevenlabs/test-api.ts new file mode 100644 index 00000000000..629f0e304dc --- /dev/null +++ b/extensions/elevenlabs/test-api.ts @@ -0,0 +1 @@ +export { buildElevenLabsSpeechProvider } from "./speech-provider.js"; diff --git a/src/media-understanding/google.video.test.ts b/extensions/google/media-understanding-provider.video.test.ts similarity index 90% rename from src/media-understanding/google.video.test.ts rename to extensions/google/media-understanding-provider.video.test.ts index 2741a9b2063..ee141c4102b 100644 --- a/src/media-understanding/google.video.test.ts +++ b/extensions/google/media-understanding-provider.video.test.ts @@ -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; 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)); diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index 7d18a46a342..e2ff060ecdf 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -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"; diff --git a/src/commands/onboard-auth.config-core.kilocode.test.ts b/extensions/kilocode/onboard.test.ts similarity index 92% rename from src/commands/onboard-auth.config-core.kilocode.test.ts rename to extensions/kilocode/onboard.test.ts index b27acab133a..ac1decd3670 100644 --- a/src/commands/onboard-auth.config-core.kilocode.test.ts +++ b/extensions/kilocode/onboard.test.ts @@ -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({ diff --git a/extensions/litellm/onboard.test.ts b/extensions/litellm/onboard.test.ts new file mode 100644 index 00000000000..4db4097c08c --- /dev/null +++ b/extensions/litellm/onboard.test.ts @@ -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", + ]); + }); +}); diff --git a/extensions/microsoft/test-api.ts b/extensions/microsoft/test-api.ts new file mode 100644 index 00000000000..de5d2beeaa0 --- /dev/null +++ b/extensions/microsoft/test-api.ts @@ -0,0 +1 @@ +export { buildMicrosoftSpeechProvider } from "./speech-provider.js"; diff --git a/extensions/minimax/onboard.test.ts b/extensions/minimax/onboard.test.ts new file mode 100644 index 00000000000..331533f0481 --- /dev/null +++ b/extensions/minimax/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/src/media-understanding/mistral.provider.test.ts b/extensions/mistral/media-understanding-provider.test.ts similarity index 84% rename from src/media-understanding/mistral.provider.test.ts rename to extensions/mistral/media-understanding-provider.test.ts index c229ab2b1ef..938cf04f164 100644 --- a/src/media-understanding/mistral.provider.test.ts +++ b/extensions/mistral/media-understanding-provider.test.ts @@ -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, diff --git a/extensions/mistral/onboard.test.ts b/extensions/mistral/onboard.test.ts new file mode 100644 index 00000000000..21c5e4f3124 --- /dev/null +++ b/extensions/mistral/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/src/media-understanding/moonshot.video.test.ts b/extensions/moonshot/media-understanding-provider.test.ts similarity index 90% rename from src/media-understanding/moonshot.video.test.ts rename to extensions/moonshot/media-understanding-provider.test.ts index def5a299021..1f650f5f947 100644 --- a/src/media-understanding/moonshot.video.test.ts +++ b/extensions/moonshot/media-understanding-provider.test.ts @@ -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, }); diff --git a/extensions/moonshot/src/kimi-web-search-provider.test.ts b/extensions/moonshot/src/kimi-web-search-provider.test.ts index b8abb33243f..05a8afdba9a 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.test.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.test.ts @@ -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"); + }); + }); }); diff --git a/src/agents/ollama-models.test.ts b/extensions/ollama/src/provider-models.test.ts similarity index 89% rename from src/agents/ollama-models.test.ts rename to extensions/ollama/src/provider-models.test.ts index fcfa77e1cab..11dd985b460 100644 --- a/src/agents/ollama-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -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(); }); diff --git a/src/commands/ollama-setup.test.ts b/extensions/ollama/src/setup.test.ts similarity index 97% rename from src/commands/ollama-setup.test.ts rename to extensions/ollama/src/setup.test.ts index 3011912837c..73a38f969e9 100644 --- a/src/commands/ollama-setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -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, })); diff --git a/src/media-understanding/openai.audio.test.ts b/extensions/openai/media-understanding-provider.test.ts similarity index 95% rename from src/media-understanding/openai.audio.test.ts rename to extensions/openai/media-understanding-provider.test.ts index c051de395e3..1808b2b1888 100644 --- a/src/media-understanding/openai.audio.test.ts +++ b/extensions/openai/media-understanding-provider.test.ts @@ -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(); diff --git a/extensions/openai/test-api.ts b/extensions/openai/test-api.ts new file mode 100644 index 00000000000..4dda287d5d5 --- /dev/null +++ b/extensions/openai/test-api.ts @@ -0,0 +1 @@ +export { buildOpenAISpeechProvider } from "./speech-provider.js"; diff --git a/extensions/openai/tts.test.ts b/extensions/openai/tts.test.ts new file mode 100644 index 00000000000..4b5797f0eb7 --- /dev/null +++ b/extensions/openai/tts.test.ts @@ -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(); + }); + }); +}); diff --git a/extensions/opencode-go/onboard.test.ts b/extensions/opencode-go/onboard.test.ts new file mode 100644 index 00000000000..0273d09f6d3 --- /dev/null +++ b/extensions/opencode-go/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/extensions/opencode/onboard.test.ts b/extensions/opencode/onboard.test.ts new file mode 100644 index 00000000000..7172e9831ad --- /dev/null +++ b/extensions/opencode/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/extensions/openrouter/onboard.test.ts b/extensions/openrouter/onboard.test.ts new file mode 100644 index 00000000000..493aa37d62a --- /dev/null +++ b/extensions/openrouter/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/test/openshell-sandbox.e2e.test.ts b/extensions/openshell/src/backend.e2e.test.ts similarity index 98% rename from test/openshell-sandbox.e2e.test.ts rename to extensions/openshell/src/backend.e2e.test.ts index 37a4d738632..91a3916eea7 100644 --- a/test/openshell-sandbox.e2e.test.ts +++ b/extensions/openshell/src/backend.e2e.test.ts @@ -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; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index d1e4868aa55..1155e4ce325 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -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", + }); + }); }); diff --git a/extensions/perplexity/test-api.ts b/extensions/perplexity/test-api.ts new file mode 100644 index 00000000000..c8d2a91ce71 --- /dev/null +++ b/extensions/perplexity/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/perplexity-web-search-provider.js"; diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index 4ef9866fa36..cdc28aa427c 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -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", () => { diff --git a/extensions/signal/test-api.ts b/extensions/signal/test-api.ts new file mode 100644 index 00000000000..867a2312b55 --- /dev/null +++ b/extensions/signal/test-api.ts @@ -0,0 +1 @@ +export { signalOutbound } from "./src/outbound-adapter.js"; diff --git a/src/channels/plugins/outbound/slack.test.ts b/extensions/slack/src/outbound-hooks.test.ts similarity index 89% rename from src/channels/plugins/outbound/slack.test.ts rename to extensions/slack/src/outbound-hooks.test.ts index 5f3e5202d85..c573b8988ae 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/extensions/slack/src/outbound-hooks.test.ts @@ -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" }); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/extensions/slack/src/outbound-payload.test.ts similarity index 95% rename from src/channels/plugins/outbound/slack.sendpayload.test.ts rename to extensions/slack/src/outbound-payload.test.ts index b3eb93e1d28..bcc92c0d302 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -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; diff --git a/extensions/slack/test-api.ts b/extensions/slack/test-api.ts index da8eceb2bd2..8d8de2e7e55 100644 --- a/extensions/slack/test-api.ts +++ b/extensions/slack/test-api.ts @@ -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"; diff --git a/extensions/synthetic/onboard.test.ts b/extensions/synthetic/onboard.test.ts new file mode 100644 index 00000000000..9b1e07f269a --- /dev/null +++ b/extensions/synthetic/onboard.test.ts @@ -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); + }); +}); diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index 900bc412c1d..eafa95c9804 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -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, diff --git a/src/media/fetch.telegram-network.test.ts b/extensions/telegram/src/fetch.network-policy.test.ts similarity index 92% rename from src/media/fetch.telegram-network.test.ts rename to extensions/telegram/src/fetch.network-policy.test.ts index da91d67312f..4721fa88668 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/extensions/telegram/src/fetch.network-policy.test.ts @@ -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[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(() => { diff --git a/extensions/telegram/test-api.ts b/extensions/telegram/test-api.ts index fe5a421d2e2..3489be66d00 100644 --- a/extensions/telegram/test-api.ts +++ b/extensions/telegram/test-api.ts @@ -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"; diff --git a/extensions/xai/onboard.test.ts b/extensions/xai/onboard.test.ts new file mode 100644 index 00000000000..6147241c3c2 --- /dev/null +++ b/extensions/xai/onboard.test.ts @@ -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, + ]); + }); +}); diff --git a/extensions/xai/src/grok-web-search-provider.test.ts b/extensions/xai/src/grok-web-search-provider.test.ts new file mode 100644 index 00000000000..15d74ca1b21 --- /dev/null +++ b/extensions/xai/src/grok-web-search-provider.test.ts @@ -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"], + }); + }); +}); diff --git a/extensions/xiaomi/onboard.test.ts b/extensions/xiaomi/onboard.test.ts new file mode 100644 index 00000000000..d7e39810e3b --- /dev/null +++ b/extensions/xiaomi/onboard.test.ts @@ -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", + ]); + }); +}); diff --git a/extensions/zai/onboard.test.ts b/extensions/zai/onboard.test.ts new file mode 100644 index 00000000000..2e1ecbd3b40 --- /dev/null +++ b/extensions/zai/onboard.test.ts @@ -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", + ); + }); +}); diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index d318d25f84a..ab8eab1a7e2 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -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, }); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 8c150cc313c..2618c0c0497 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -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); }); diff --git a/src/agents/pi-embedded-subscribe.raw-stream.ts b/src/agents/pi-embedded-subscribe.raw-stream.ts index eaf156b64d4..12efd4d0cac 100644 --- a/src/agents/pi-embedded-subscribe.raw-stream.ts +++ b/src/agents/pi-embedded-subscribe.raw-stream.ts @@ -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) { - 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 } diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index 044edc93a6d..b0c22b91f47 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -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", }, diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 49e149bf934..31069d23657 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -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"], - }); - }); -}); diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index 46c92b84df0..3a6b0a5a4fd 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 3637c1ef874..d6ca7a14e20 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -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", diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 227d0c896b8..66e8b2a04c7 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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(); - 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(); - 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( "../../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[0]>, +) { + expect(mocks.deliverOutboundPayloads).toHaveBeenLastCalledWith(expect.objectContaining(matcher)); +} + +async function expectSlackNoDelivery( payload: Parameters[0]["payload"], overrides: Partial[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", - }, -]); diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index f8f289e0afa..4a74985391b 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -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(); - }); -}); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 2eb27d1c9f6..ea420bbaf3f 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -36,9 +36,6 @@ async function collectDoctorWarnings(config: Record): Promise { 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 } - >; - accounts: Record; - }; - }; - }; - 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; - }; - }; - }; - 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; - }; - }; - }; - 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(["*"]); }); }); diff --git a/src/commands/doctor/providers/telegram.test.ts b/src/commands/doctor/providers/telegram.test.ts index e67a81f6968..f4544d477b1 100644 --- a/src/commands/doctor/providers/telegram.test.ts +++ b/src/commands/doctor/providers/telegram.test.ts @@ -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(); + return { + ...actual, + listTelegramAccountIds: listTelegramAccountIdsMock, + inspectTelegramAccount: inspectTelegramAccountMock, + lookupTelegramChatId: lookupTelegramChatIdMock, + }; +}); + +vi.mock("../../../plugin-sdk/account-resolution.js", async (importOriginal) => { + const actual = await importOriginal(); + 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 | 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 } + >; + accounts: Record; + }; + }; + }; + 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; + }; + }; + }; + 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" }, + }); + }); }); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 707310d5940..3da240f2a60 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -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 = {}; let testStore: Record = {}; -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; + 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(); - 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) { + const channels = cfg.channels as Record | undefined; + return (channels?.telegram as Record | undefined) ?? {}; +} + +function listTelegramAccountIdsForTest(cfg: Record): string[] { + const telegram = getTelegramChannelConfig(cfg); + const accounts = telegram.accounts as Record | 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; + accountId?: string | null; +}): TelegramHealthAccount { + const telegram = getTelegramChannelConfig(params.cfg); + const accounts = (telegram.accounts as Record> | 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 } + : {}), + ...(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) + : 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> { + 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), + resolveAccount: (cfg, accountId) => + resolveTelegramAccountForTest({ cfg: cfg as Record, 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, diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index e59f3caa870..2b0c54e1e67 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -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) { - expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([ - ...EXPECTED_FALLBACKS, - ]); -} - -function expectPrimaryModelPreserved(cfg: ReturnType) { - expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( - "anthropic/claude-opus-4-5", - ); -} - -function expectAllowlistContains( - cfg: ReturnType, - key: string, -) { - const models = cfg.agents?.defaults?.models ?? {}; - expect(Object.keys(models)).toContain(key); -} - -function expectAliasPreserved( - cfg: ReturnType, - 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); - } - }); -}); diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c2763c53cb6..938270928c2 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -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, }), ); }); diff --git a/src/cron/isolated-agent.delivery-target-thread-session.test.ts b/src/cron/isolated-agent.delivery-target-thread-session.test.ts index cd1173b1d90..93cd16e4381 100644 --- a/src/cron/isolated-agent.delivery-target-thread-session.test.ts +++ b/src/cron/isolated-agent.delivery-target-thread-session.test.ts @@ -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> = {}; @@ -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") }, diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index a2936bd0f51..1ccf4f85cb3 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -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); + vi.mocked(whatsappAccountMocks.resolveWhatsAppAccount).mockReturnValue({ allowFrom }); } function setStoredWhatsAppAllowFrom(allowFrom: string[]) { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 042e3714fec..132c7d7c0e5 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -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([ diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 919dc20b388..311cce9d46a 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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 | 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, diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 131ba843062..6db32f66795 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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 () => { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 52abfa5929e..4fc740a92e8 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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", }); diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 75781bfe29b..0a08c0d5a32 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -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" } } }, diff --git a/test/channel-outbounds.ts b/test/channel-outbounds.ts index a6da5a1c333..786fb394102 100644 --- a/test/channel-outbounds.ts +++ b/test/channel-outbounds.ts @@ -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"; diff --git a/test/helpers/extensions/onboard-config.ts b/test/helpers/extensions/onboard-config.ts new file mode 100644 index 00000000000..0934f1c6346 --- /dev/null +++ b/test/helpers/extensions/onboard-config.ts @@ -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] }, + }, + }, + }; +}