From cd9254911985b6e1b9e4d028e91fd4c76459fd34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 16:06:20 +0000 Subject: [PATCH] test: split extension-owned core coverage --- .../discord/src/actions/handle-action.test.ts | 90 +++ extensions/signal/src/message-actions.test.ts | 171 ++++++ .../telegram/src/channel-actions.test.ts | 208 +++++++ extensions/voice-call/index.test.ts | 238 ++++++++ src/auto-reply/reply/reply-plumbing.test.ts | 30 +- src/commands/auth-choice.test.ts | 567 ++++++++++++++++-- src/commands/doctor-config-flow.test.ts | 2 +- src/commands/health.snapshot.test.ts | 2 +- src/commands/message.test.ts | 44 -- src/commands/status.test.ts | 8 +- src/infra/exec-approval-surface.test.ts | 18 - src/infra/matrix-account-selection.test.ts | 4 +- src/infra/matrix-legacy-crypto.test.ts | 2 +- src/infra/matrix-migration-snapshot.test.ts | 2 +- 14 files changed, 1255 insertions(+), 131 deletions(-) create mode 100644 extensions/discord/src/actions/handle-action.test.ts create mode 100644 extensions/signal/src/message-actions.test.ts create mode 100644 extensions/voice-call/index.test.ts diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts new file mode 100644 index 00000000000..33b2228234e --- /dev/null +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; + +const runtimeModule = await import("./runtime.js"); +const handleDiscordActionMock = vi + .spyOn(runtimeModule, "handleDiscordAction") + .mockResolvedValue({ content: [], details: { ok: true } }); +const { handleDiscordMessageAction } = await import("./handle-action.js"); + +describe("handleDiscordMessageAction", () => { + beforeEach(() => { + handleDiscordActionMock.mockClear(); + }); + + it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => { + await handleDiscordMessageAction({ + action: "timeout", + params: { + guildId: "guild-1", + userId: "user-2", + durationMin: 5, + senderUserId: "spoofed-admin-id", + }, + cfg: { + channels: { discord: { token: "tok", actions: { moderation: true } } }, + } as OpenClawConfig, + requesterSenderId: "trusted-sender-id", + toolContext: { currentChannelProvider: "discord" }, + }); + + expect(handleDiscordActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "timeout", + guildId: "guild-1", + userId: "user-2", + durationMinutes: 5, + senderUserId: "trusted-sender-id", + }), + expect.objectContaining({ + channels: { + discord: expect.objectContaining({ + token: "tok", + }), + }, + }), + ); + }); + + it("falls back to toolContext.currentMessageId for reactions", async () => { + await handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig, + toolContext: { currentMessageId: "9001" }, + }); + + expect(handleDiscordActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "react", + channelId: "123", + messageId: "9001", + emoji: "ok", + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("rejects reactions when no message id source is available", async () => { + await expect( + handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig, + }), + ).rejects.toThrow(/messageId required/i); + + expect(handleDiscordActionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/signal/src/message-actions.test.ts b/extensions/signal/src/message-actions.test.ts new file mode 100644 index 00000000000..f3a3b9adc60 --- /dev/null +++ b/extensions/signal/src/message-actions.test.ts @@ -0,0 +1,171 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendReactionsModule = await import("./send-reactions.js"); +const sendReactionSignalMock = vi + .spyOn(sendReactionsModule, "sendReactionSignal") + .mockResolvedValue({ ok: true }); +const removeReactionSignalMock = vi + .spyOn(sendReactionsModule, "removeReactionSignal") + .mockResolvedValue({ ok: true }); +const { signalMessageActions } = await import("./message-actions.js"); + +function createSignalAccountOverrideCfg(): OpenClawConfig { + return { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; +} + +describe("signalMessageActions", () => { + beforeEach(() => { + sendReactionSignalMock.mockClear(); + removeReactionSignalMock.mockClear(); + }); + + it("lists actions based on configured accounts and reaction gates", () => { + expect( + signalMessageActions.describeMessageTool?.({ cfg: {} as OpenClawConfig })?.actions ?? [], + ).toEqual([]); + + expect( + signalMessageActions.describeMessageTool?.({ + cfg: { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig, + })?.actions, + ).toEqual(["send"]); + + expect( + signalMessageActions.describeMessageTool?.({ cfg: createSignalAccountOverrideCfg() }) + ?.actions, + ).toEqual(["send", "react"]); + }); + + it("skips send for plugin dispatch", () => { + expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); + expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); + }); + + it("blocks reactions when the action gate is disabled", async () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction?.({ + channel: "signal", + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "✅" }, + cfg, + }), + ).rejects.toThrow(/actions\.reactions/); + }); + + it("maps reaction targets into sendReactionSignal calls", async () => { + const cases = [ + { + name: "uses account-level actions when enabled", + cfg: createSignalAccountOverrideCfg(), + accountId: "work", + params: { to: "+15550001111", messageId: "123", emoji: "👍" }, + expectedRecipient: "+15550001111", + expectedTimestamp: 123, + expectedEmoji: "👍", + expectedOptions: { accountId: "work" }, + }, + { + name: "normalizes uuid recipients", + cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, + params: { + recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "🔥", + }, + expectedRecipient: "123e4567-e89b-12d3-a456-426614174000", + expectedTimestamp: 123, + expectedEmoji: "🔥", + expectedOptions: {}, + }, + { + name: "passes groupId and targetAuthor for group reactions", + cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, + params: { + to: "signal:group:group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "✅", + }, + expectedRecipient: "", + expectedTimestamp: 123, + expectedEmoji: "✅", + expectedOptions: { + groupId: "group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + }, + }, + { + name: "falls back to toolContext.currentMessageId when messageId is omitted", + cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, + params: { to: "+15559999999", emoji: "🔥" }, + expectedRecipient: "+15559999999", + expectedTimestamp: 1737630212345, + expectedEmoji: "🔥", + expectedOptions: {}, + toolContext: { currentMessageId: "1737630212345" }, + }, + ] as const; + + for (const testCase of cases) { + sendReactionSignalMock.mockClear(); + await signalMessageActions.handleAction?.({ + channel: "signal", + action: "react", + params: testCase.params, + cfg: testCase.cfg, + accountId: "accountId" in testCase ? testCase.accountId : undefined, + toolContext: "toolContext" in testCase ? testCase.toolContext : undefined, + }); + + expect(sendReactionSignalMock, testCase.name).toHaveBeenCalledWith( + testCase.expectedRecipient, + testCase.expectedTimestamp, + testCase.expectedEmoji, + expect.objectContaining({ + cfg: testCase.cfg, + ...testCase.expectedOptions, + }), + ); + } + }); + + it("rejects invalid reaction inputs before dispatch", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction?.({ + channel: "signal", + action: "react", + params: { to: "+15559999999", emoji: "✅" }, + cfg, + }), + ).rejects.toThrow(/messageId.*required/); + + await expect( + signalMessageActions.handleAction?.({ + channel: "signal", + action: "react", + params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, + cfg, + }), + ).rejects.toThrow(/targetAuthor/); + }); +}); diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index a75a0f0c71f..9a319525673 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { telegramMessageActions, telegramMessageActionRuntime } from "./channel-actions.js"; const handleTelegramActionMock = vi.hoisted(() => vi.fn()); @@ -58,4 +59,211 @@ describe("telegramMessageActions", () => { }), ); }); + + it("computes poll/topic action availability from config gates", () => { + const cases = [ + { + name: "configured telegram enables poll", + cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig, + expectPoll: true, + expectTopicEdit: true, + }, + { + name: "sendMessage disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { sendMessage: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "poll gate disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { poll: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "split account gates do not expose poll", + cfg: { + channels: { + telegram: { + accounts: { + senderOnly: { + botToken: "tok-send", + actions: { + sendMessage: true, + poll: false, + }, + }, + pollOnly: { + botToken: "tok-poll", + actions: { + sendMessage: false, + poll: true, + }, + }, + }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + ] as const; + + for (const testCase of cases) { + const actions = + telegramMessageActions.describeMessageTool?.({ + cfg: testCase.cfg, + })?.actions ?? []; + if (testCase.expectPoll) { + expect(actions, testCase.name).toContain("poll"); + } else { + expect(actions, testCase.name).not.toContain("poll"); + } + if (testCase.expectTopicEdit) { + expect(actions, testCase.name).toContain("topic-edit"); + } else { + expect(actions, testCase.name).not.toContain("topic-edit"); + } + } + }); + + it("lists sticker actions only when enabled by config", () => { + const cases = [ + { + name: "default config", + cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig, + expectSticker: false, + }, + { + name: "per-account sticker enabled", + cfg: { + channels: { + telegram: { + accounts: { + media: { botToken: "tok", actions: { sticker: true } }, + }, + }, + }, + } as OpenClawConfig, + expectSticker: true, + }, + { + name: "all accounts omit sticker", + cfg: { + channels: { + telegram: { + accounts: { + a: { botToken: "tok1" }, + b: { botToken: "tok2" }, + }, + }, + }, + } as OpenClawConfig, + expectSticker: false, + }, + ] as const; + + for (const testCase of cases) { + const actions = + telegramMessageActions.describeMessageTool?.({ + cfg: testCase.cfg, + })?.actions ?? []; + if (testCase.expectSticker) { + expect(actions, testCase.name).toEqual( + expect.arrayContaining(["sticker", "sticker-search"]), + ); + } else { + expect(actions, testCase.name).not.toContain("sticker"); + expect(actions, testCase.name).not.toContain("sticker-search"); + } + } + }); + + it("normalizes reaction message identifiers before dispatch", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cases = [ + { + name: "numeric channelId/messageId", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + expectedChannelField: "channelId", + expectedChannelValue: "123", + expectedMessageId: "456", + }, + { + name: "snake_case message_id", + params: { + channelId: 123, + message_id: "456", + emoji: "ok", + }, + expectedChannelField: "channelId", + expectedChannelValue: "123", + expectedMessageId: "456", + }, + { + name: "toolContext fallback", + params: { + chatId: "123", + emoji: "ok", + }, + toolContext: { currentMessageId: "9001" }, + expectedChannelField: "chatId", + expectedChannelValue: "123", + expectedMessageId: "9001", + }, + { + name: "missing messageId soft-falls through", + params: { + chatId: "123", + emoji: "ok", + }, + expectedChannelField: "chatId", + expectedChannelValue: "123", + expectedMessageId: undefined, + }, + ] as const; + + for (const testCase of cases) { + handleTelegramActionMock.mockClear(); + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: testCase.params, + cfg, + toolContext: "toolContext" in testCase ? testCase.toolContext : undefined, + }); + + const call = handleTelegramActionMock.mock.calls[0]?.[0] as + | Record + | undefined; + expect(call, testCase.name).toBeDefined(); + expect(call?.action, testCase.name).toBe("react"); + expect(String(call?.[testCase.expectedChannelField]), testCase.name).toBe( + testCase.expectedChannelValue, + ); + if (testCase.expectedMessageId === undefined) { + expect(call?.messageId, testCase.name).toBeUndefined(); + } else { + expect(String(call?.messageId), testCase.name).toBe(testCase.expectedMessageId); + } + } + }); }); diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts new file mode 100644 index 00000000000..b71135bcb99 --- /dev/null +++ b/extensions/voice-call/index.test.ts @@ -0,0 +1,238 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let runtimeStub: { + config: { toNumber?: string }; + manager: { + initiateCall: ReturnType; + continueCall: ReturnType; + speak: ReturnType; + endCall: ReturnType; + getCall: ReturnType; + getCallByProviderCallId: ReturnType; + }; + stop: ReturnType; +}; + +vi.mock("./runtime-entry.js", () => ({ + createVoiceCallRuntime: vi.fn(async () => runtimeStub), +})); + +import plugin from "./index.js"; + +const noopLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +type Registered = { + methods: Map; + tools: unknown[]; +}; +type RegisterVoiceCall = (api: Record) => void | Promise; +type RegisterCliContext = { + program: Command; + config: Record; + workspaceDir?: string; + logger: typeof noopLogger; +}; + +function captureStdout() { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + return { + output: () => output, + restore: () => writeSpy.mockRestore(), + }; +} +function setup(config: Record): Registered { + const methods = new Map(); + const tools: unknown[] = []; + void plugin.register({ + id: "voice-call", + name: "Voice Call", + description: "test", + version: "0", + source: "test", + config: {}, + pluginConfig: config, + runtime: { tts: { textToSpeechTelephony: vi.fn() } } as unknown as Parameters< + typeof plugin.register + >[0]["runtime"], + logger: noopLogger, + registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler), + registerTool: (tool: unknown) => tools.push(tool), + registerCli: () => {}, + registerService: () => {}, + resolvePath: (p: string) => p, + } as unknown as Parameters[0]); + return { methods, tools }; +} + +async function registerVoiceCallCli(program: Command) { + const { register } = plugin as unknown as { + register: RegisterVoiceCall; + }; + await register({ + id: "voice-call", + name: "Voice Call", + description: "test", + version: "0", + source: "test", + config: {}, + pluginConfig: { provider: "mock" }, + runtime: { tts: { textToSpeechTelephony: vi.fn() } }, + logger: noopLogger, + registerGatewayMethod: () => {}, + registerTool: () => {}, + registerCli: (fn: (ctx: RegisterCliContext) => void) => + fn({ + program, + config: {}, + workspaceDir: undefined, + logger: noopLogger, + }), + registerService: () => {}, + resolvePath: (p: string) => p, + }); +} + +describe("voice-call plugin", () => { + beforeEach(() => { + runtimeStub = { + config: { toNumber: "+15550001234" }, + manager: { + initiateCall: vi.fn(async () => ({ callId: "call-1", success: true })), + continueCall: vi.fn(async () => ({ + success: true, + transcript: "hello", + })), + speak: vi.fn(async () => ({ success: true })), + endCall: vi.fn(async () => ({ success: true })), + getCall: vi.fn((id: string) => (id === "call-1" ? { callId: "call-1" } : undefined)), + getCallByProviderCallId: vi.fn(() => undefined), + }, + stop: vi.fn(async () => {}), + }; + }); + + afterEach(() => vi.restoreAllMocks()); + + it("registers gateway methods", () => { + const { methods } = setup({ provider: "mock" }); + expect(methods.has("voicecall.initiate")).toBe(true); + expect(methods.has("voicecall.continue")).toBe(true); + expect(methods.has("voicecall.speak")).toBe(true); + expect(methods.has("voicecall.end")).toBe(true); + expect(methods.has("voicecall.status")).toBe(true); + expect(methods.has("voicecall.start")).toBe(true); + }); + + it("initiates a call via voicecall.initiate", async () => { + const { methods } = setup({ provider: "mock" }); + const handler = methods.get("voicecall.initiate") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + await handler?.({ params: { message: "Hi" }, respond }); + expect(runtimeStub.manager.initiateCall).toHaveBeenCalled(); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.callId).toBe("call-1"); + }); + + it("returns call status", async () => { + const { methods } = setup({ provider: "mock" }); + const handler = methods.get("voicecall.status") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + await handler?.({ params: { callId: "call-1" }, respond }); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.found).toBe(true); + }); + + it("tool get_status returns json payload", async () => { + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + const result = (await tool.execute("id", { + action: "get_status", + callId: "call-1", + })) as { details: { found?: boolean } }; + expect(result.details.found).toBe(true); + }); + + it("legacy tool status without sid returns error payload", async () => { + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + const result = (await tool.execute("id", { mode: "status" })) as { + details: { error?: unknown }; + }; + expect(String(result.details.error)).toContain("sid required"); + }); + + it("CLI latency summarizes turn metrics from JSONL", async () => { + const program = new Command(); + const tmpFile = path.join(os.tmpdir(), `voicecall-latency-${Date.now()}.jsonl`); + fs.writeFileSync( + tmpFile, + [ + JSON.stringify({ metadata: { lastTurnLatencyMs: 100, lastTurnListenWaitMs: 70 } }), + JSON.stringify({ metadata: { lastTurnLatencyMs: 200, lastTurnListenWaitMs: 110 } }), + ].join("\n") + "\n", + "utf8", + ); + + const stdout = captureStdout(); + + try { + await registerVoiceCallCli(program); + + await program.parseAsync(["voicecall", "latency", "--file", tmpFile, "--last", "10"], { + from: "user", + }); + + const printed = stdout.output(); + expect(printed).toContain('"recordsScanned": 2'); + expect(printed).toContain('"p50Ms": 100'); + expect(printed).toContain('"p95Ms": 200'); + } finally { + stdout.restore(); + fs.unlinkSync(tmpFile); + } + }); + + it("CLI start prints JSON", async () => { + const program = new Command(); + const stdout = captureStdout(); + await registerVoiceCallCli(program); + + try { + await program.parseAsync(["voicecall", "start", "--to", "+1", "--message", "Hello"], { + from: "user", + }); + expect(stdout.output()).toContain('"callId": "call-1"'); + } finally { + stdout.restore(); + } + }); +}); diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index c960a56876c..0315f4adc05 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/index.js"; import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import type { TemplateContext } from "../templating.js"; import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { applyReplyThreading } from "./reply-payloads.js"; @@ -15,6 +18,20 @@ import { sortSubagentRuns, } from "./subagents-utils.js"; +function createSlackThreadingPlugin(): ChannelPlugin { + return { + ...createChannelTestPluginBase({ id: "slack", label: "Slack" }), + threading: { + buildToolContext: ({ context }) => ({ + currentChannelId: context.To?.replace(/^channel:/, ""), + currentThreadTs: + context.MessageThreadId != null ? String(context.MessageThreadId) : undefined, + replyToMode: "all", + }), + }, + } as ChannelPlugin; +} + describe("buildThreadingToolContext", () => { const cfg = {} as OpenClawConfig; @@ -157,7 +174,9 @@ describe("buildThreadingToolContext", () => { it("uses Slack plugin threading context when the plugin registry is active", () => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "slack", plugin: slackPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "slack", plugin: createSlackThreadingPlugin(), source: "test" }, + ]), ); const sessionCtx = { Provider: "slack", @@ -244,7 +263,8 @@ describe("applyReplyThreading auto-threading", () => { }); expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToId).toBeUndefined(); + expect(result[0].replyToTag).toBe(true); }); it("keeps explicit tags for Telegram when off mode is enabled", () => { @@ -256,7 +276,7 @@ describe("applyReplyThreading auto-threading", () => { }); expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToId).toBeUndefined(); expect(result[0].replyToTag).toBe(true); }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 37d8dd12180..0a1ec3cf7dc 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1,39 +1,18 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import anthropicPlugin from "../../extensions/anthropic/index.js"; -import chutesPlugin from "../../extensions/chutes/index.js"; -import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js"; -import googlePlugin from "../../extensions/google/index.js"; -import huggingfacePlugin from "../../extensions/huggingface/index.js"; -import kimiCodingPlugin from "../../extensions/kimi-coding/index.js"; -import litellmPlugin from "../../extensions/litellm/index.js"; -import minimaxPlugin from "../../extensions/minimax/index.js"; -import mistralPlugin from "../../extensions/mistral/index.js"; -import moonshotPlugin from "../../extensions/moonshot/index.js"; -import ollamaPlugin from "../../extensions/ollama/index.js"; -import openAIPlugin from "../../extensions/openai/index.js"; -import opencodeGoPlugin from "../../extensions/opencode-go/index.js"; -import opencodePlugin from "../../extensions/opencode/index.js"; -import openrouterPlugin from "../../extensions/openrouter/index.js"; -import qianfanPlugin from "../../extensions/qianfan/index.js"; -import syntheticPlugin from "../../extensions/synthetic/index.js"; -import togetherPlugin from "../../extensions/together/index.js"; -import venicePlugin from "../../extensions/venice/index.js"; -import vercelAiGatewayPlugin from "../../extensions/vercel-ai-gateway/index.js"; -import xaiPlugin from "../../extensions/xai/index.js"; -import xiaomiPlugin from "../../extensions/xiaomi/index.js"; -import { setDetectZaiEndpointForTesting } from "../../extensions/zai/detect.js"; -import zaiPlugin from "../../extensions/zai/index.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { ModelProviderConfig } from "../config/types.models.js"; +import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; +import { providerApiKeyAuthRuntime } from "../plugins/provider-api-key-auth.runtime.js"; import { MINIMAX_CN_API_BASE_URL, ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, } from "../plugins/provider-model-definitions.js"; -import type { ProviderPlugin } from "../plugins/types.js"; -import { registerProviderPlugins } from "../test-utils/plugin-registration.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js"; @@ -50,10 +29,6 @@ import { type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; -vi.mock("../../extensions/github-copilot/login.js", () => ({ - githubCopilotLoginCommand: vi.fn(async () => {}), -})); - const loginOpenAICodexOAuth = vi.hoisted(() => vi.fn<() => Promise>(async () => null), ); @@ -87,32 +62,512 @@ type StoredAuthProfile = { metadata?: Record; }; +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function providerConfigPatch( + providerId: string, + patch: Record, +): Partial { + const providers: Record = { + [providerId]: patch as ModelProviderConfig, + }; + return { + models: { + providers, + }, + }; +} + +function createApiKeyProvider(params: { + providerId: string; + label: string; + choiceId: string; + optionKey: string; + flagName: `--${string}`; + envVar: string; + promptMessage: string; + defaultModel?: string; + profileId?: string; + profileIds?: string[]; + expectedProviders?: string[]; + noteMessage?: string; + noteTitle?: string; + applyConfig?: Partial; +}): ProviderPlugin { + return { + id: params.providerId, + label: params.label, + auth: [ + createProviderApiKeyAuthMethod({ + providerId: params.providerId, + methodId: "api-key", + label: params.label, + optionKey: params.optionKey, + flagName: params.flagName, + envVar: params.envVar, + promptMessage: params.promptMessage, + ...(params.profileId ? { profileId: params.profileId } : {}), + ...(params.profileIds ? { profileIds: params.profileIds } : {}), + ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + ...(params.expectedProviders ? { expectedProviders: params.expectedProviders } : {}), + ...(params.noteMessage ? { noteMessage: params.noteMessage } : {}), + ...(params.noteTitle ? { noteTitle: params.noteTitle } : {}), + ...(params.applyConfig ? { applyConfig: () => params.applyConfig as OpenClawConfig } : {}), + wizard: { + choiceId: params.choiceId, + choiceLabel: params.label, + groupId: params.providerId, + groupLabel: params.label, + }, + }), + ], + }; +} + +function createFixedChoiceProvider(params: { + providerId: string; + label: string; + choiceId: string; + method: ProviderAuthMethod; +}): ProviderPlugin { + return { + id: params.providerId, + label: params.label, + auth: [ + { + ...params.method, + wizard: { + choiceId: params.choiceId, + choiceLabel: params.label, + groupId: params.providerId, + groupLabel: params.label, + }, + }, + ], + }; +} + function createDefaultProviderPlugins() { - return registerProviderPlugins( - anthropicPlugin, - chutesPlugin, - cloudflareAiGatewayPlugin, - googlePlugin, - huggingfacePlugin, - kimiCodingPlugin, - litellmPlugin, - minimaxPlugin, - mistralPlugin, - moonshotPlugin, - ollamaPlugin, - openAIPlugin, - opencodeGoPlugin, - opencodePlugin, - openrouterPlugin, - qianfanPlugin, - syntheticPlugin, - togetherPlugin, - venicePlugin, - vercelAiGatewayPlugin, - xaiPlugin, - xiaomiPlugin, - zaiPlugin, - ); + const buildApiKeyCredential = providerApiKeyAuthRuntime.buildApiKeyCredential; + const ensureApiKeyFromOptionEnvOrPrompt = + providerApiKeyAuthRuntime.ensureApiKeyFromOptionEnvOrPrompt; + const normalizeApiKeyInput = providerApiKeyAuthRuntime.normalizeApiKeyInput; + const validateApiKeyInput = providerApiKeyAuthRuntime.validateApiKeyInput; + + const createZaiMethod = (choiceId: "zai-api-key" | "zai-coding-global"): ProviderAuthMethod => ({ + id: choiceId === "zai-api-key" ? "api-key" : "coding-global", + label: "Z.AI API key", + kind: "api_key", + wizard: { + choiceId, + choiceLabel: "Z.AI API key", + groupId: "zai", + groupLabel: "Z.AI", + }, + run: async (ctx) => { + const token = normalizeText(await ctx.prompter.text({ message: "Enter Z.AI API key" })); + const detectResult = await detectZaiEndpoint( + choiceId === "zai-coding-global" + ? { apiKey: token, endpoint: "coding-global" } + : { apiKey: token }, + ); + let baseUrl = detectResult?.baseUrl; + let modelId = detectResult?.modelId; + if (!baseUrl || !modelId) { + if (choiceId === "zai-coding-global") { + baseUrl = ZAI_CODING_GLOBAL_BASE_URL; + modelId = "glm-5"; + } else { + const endpoint = await ctx.prompter.select({ + message: "Select Z.AI endpoint", + initialValue: "global", + options: [ + { label: "Global", value: "global" }, + { label: "Coding CN", value: "coding-cn" }, + ], + }); + baseUrl = endpoint === "coding-cn" ? ZAI_CODING_CN_BASE_URL : ZAI_CODING_GLOBAL_BASE_URL; + modelId = "glm-5"; + } + } + return { + profiles: [ + { + profileId: "zai:default", + credential: buildApiKeyCredential("zai", token), + }, + ], + configPatch: providerConfigPatch("zai", { baseUrl }) as OpenClawConfig, + defaultModel: `zai/${modelId}`, + }; + }, + }); + + const cloudflareAiGatewayMethod: ProviderAuthMethod = { + id: "api-key", + label: "Cloudflare AI Gateway API key", + kind: "api_key", + wizard: { + choiceId: "cloudflare-ai-gateway-api-key", + choiceLabel: "Cloudflare AI Gateway API key", + groupId: "cloudflare-ai-gateway", + groupLabel: "Cloudflare AI Gateway", + }, + run: async (ctx) => { + const opts = (ctx.opts ?? {}) as Record; + const accountId = + normalizeText(opts.cloudflareAiGatewayAccountId) || + normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare account ID" })); + const gatewayId = + normalizeText(opts.cloudflareAiGatewayGatewayId) || + normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare gateway ID" })); + let capturedSecretInput = ""; + let capturedMode: "plaintext" | "ref" | undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ + token: + normalizeText(opts.cloudflareAiGatewayApiKey) || + normalizeText(ctx.opts?.token) || + undefined, + tokenProvider: "cloudflare-ai-gateway", + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, + config: ctx.config, + expectedProviders: ["cloudflare-ai-gateway"], + provider: "cloudflare-ai-gateway", + envLabel: "CLOUDFLARE_AI_GATEWAY_API_KEY", + promptMessage: "Enter Cloudflare AI Gateway API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = typeof apiKey === "string" ? apiKey : ""; + capturedMode = mode; + }, + }); + return { + profiles: [ + { + profileId: "cloudflare-ai-gateway:default", + credential: buildApiKeyCredential( + "cloudflare-ai-gateway", + capturedSecretInput, + { accountId, gatewayId }, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + }, + ], + defaultModel: "cloudflare-ai-gateway/claude-sonnet-4-5", + }; + }, + }; + + const chutesOAuthMethod: ProviderAuthMethod = { + id: "oauth", + label: "Chutes OAuth", + kind: "device_code", + wizard: { + choiceId: "chutes", + choiceLabel: "Chutes", + groupId: "chutes", + groupLabel: "Chutes", + }, + run: async (ctx) => { + const state = "state-test"; + ctx.runtime.log(`Open this URL: https://api.chutes.ai/idp/authorize?state=${state}`); + const redirect = String( + await ctx.prompter.text({ message: "Paste the redirect URL or code" }), + ); + const params = new URLSearchParams(redirect.startsWith("?") ? redirect.slice(1) : redirect); + const code = params.get("code") ?? redirect; + const tokenResponse = await fetch("https://api.chutes.ai/idp/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, client_id: process.env.CHUTES_CLIENT_ID }), + }); + const tokenJson = (await tokenResponse.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + const userResponse = await fetch("https://api.chutes.ai/idp/userinfo", { + headers: { Authorization: `Bearer ${tokenJson.access_token}` }, + }); + const userJson = (await userResponse.json()) as { username: string }; + return { + profiles: [ + { + profileId: `chutes:${userJson.username}`, + credential: { + type: "oauth", + provider: "chutes", + access: tokenJson.access_token, + refresh: tokenJson.refresh_token, + expires: Date.now() + tokenJson.expires_in * 1000, + email: userJson.username, + }, + }, + ], + }; + }, + }; + + return [ + createApiKeyProvider({ + providerId: "anthropic", + label: "Anthropic API key", + choiceId: "apiKey", + optionKey: "anthropicApiKey", + flagName: "--anthropic-api-key", + envVar: "ANTHROPIC_API_KEY", + promptMessage: "Enter Anthropic API key", + }), + createApiKeyProvider({ + providerId: "google", + label: "Gemini API key", + choiceId: "gemini-api-key", + optionKey: "geminiApiKey", + flagName: "--gemini-api-key", + envVar: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, + }), + createApiKeyProvider({ + providerId: "huggingface", + label: "Hugging Face API key", + choiceId: "huggingface-api-key", + optionKey: "huggingfaceApiKey", + flagName: "--huggingface-api-key", + envVar: "HUGGINGFACE_HUB_TOKEN", + promptMessage: "Enter Hugging Face API key", + defaultModel: "huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct", + }), + createApiKeyProvider({ + providerId: "litellm", + label: "LiteLLM API key", + choiceId: "litellm-api-key", + optionKey: "litellmApiKey", + flagName: "--litellm-api-key", + envVar: "LITELLM_API_KEY", + promptMessage: "Enter LiteLLM API key", + defaultModel: "litellm/anthropic/claude-opus-4.6", + }), + createApiKeyProvider({ + providerId: "minimax", + label: "MiniMax API key (Global)", + choiceId: "minimax-global-api", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: "Enter MiniMax API key", + profileId: "minimax:global", + defaultModel: "minimax/MiniMax-M2.7", + }), + createApiKeyProvider({ + providerId: "minimax", + label: "MiniMax API key (CN)", + choiceId: "minimax-cn-api", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: "Enter MiniMax CN API key", + profileId: "minimax:cn", + defaultModel: "minimax/MiniMax-M2.7", + applyConfig: providerConfigPatch("minimax", { baseUrl: MINIMAX_CN_API_BASE_URL }), + expectedProviders: ["minimax", "minimax-cn"], + }), + createApiKeyProvider({ + providerId: "mistral", + label: "Mistral API key", + choiceId: "mistral-api-key", + optionKey: "mistralApiKey", + flagName: "--mistral-api-key", + envVar: "MISTRAL_API_KEY", + promptMessage: "Enter Mistral API key", + defaultModel: "mistral/mistral-large-latest", + }), + createApiKeyProvider({ + providerId: "moonshot", + label: "Moonshot API key", + choiceId: "moonshot-api-key", + optionKey: "moonshotApiKey", + flagName: "--moonshot-api-key", + envVar: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key", + defaultModel: "moonshot/moonshot-v1-128k", + }), + createFixedChoiceProvider({ + providerId: "ollama", + label: "Ollama", + choiceId: "ollama", + method: { + id: "local", + label: "Ollama", + kind: "custom", + run: async () => ({ profiles: [] }), + }, + }), + createApiKeyProvider({ + providerId: "openai", + label: "OpenAI API key", + choiceId: "openai-api-key", + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + promptMessage: "Enter OpenAI API key", + defaultModel: "openai/gpt-5.4", + }), + createApiKeyProvider({ + providerId: "opencode", + label: "OpenCode Zen", + choiceId: "opencode-zen", + optionKey: "opencodeZenApiKey", + flagName: "--opencode-zen-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + profileIds: ["opencode:default", "opencode-go:default"], + defaultModel: "opencode/claude-opus-4-6", + expectedProviders: ["opencode", "opencode-go"], + noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.", + noteTitle: "OpenCode", + }), + createApiKeyProvider({ + providerId: "opencode-go", + label: "OpenCode Go", + choiceId: "opencode-go", + optionKey: "opencodeGoApiKey", + flagName: "--opencode-go-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + profileIds: ["opencode-go:default", "opencode:default"], + defaultModel: "opencode-go/kimi-k2.5", + expectedProviders: ["opencode", "opencode-go"], + noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.", + noteTitle: "OpenCode", + }), + createApiKeyProvider({ + providerId: "openrouter", + label: "OpenRouter API key", + choiceId: "openrouter-api-key", + optionKey: "openrouterApiKey", + flagName: "--openrouter-api-key", + envVar: "OPENROUTER_API_KEY", + promptMessage: "Enter OpenRouter API key", + defaultModel: "openrouter/auto", + }), + createApiKeyProvider({ + providerId: "qianfan", + label: "Qianfan API key", + choiceId: "qianfan-api-key", + optionKey: "qianfanApiKey", + flagName: "--qianfan-api-key", + envVar: "QIANFAN_API_KEY", + promptMessage: "Enter Qianfan API key", + defaultModel: "qianfan/ernie-4.5-8k", + }), + createApiKeyProvider({ + providerId: "synthetic", + label: "Synthetic API key", + choiceId: "synthetic-api-key", + optionKey: "syntheticApiKey", + flagName: "--synthetic-api-key", + envVar: "SYNTHETIC_API_KEY", + promptMessage: "Enter Synthetic API key", + defaultModel: "synthetic/Synthetic-1", + }), + createApiKeyProvider({ + providerId: "together", + label: "Together API key", + choiceId: "together-api-key", + optionKey: "togetherApiKey", + flagName: "--together-api-key", + envVar: "TOGETHER_API_KEY", + promptMessage: "Enter Together API key", + defaultModel: "together/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + }), + createApiKeyProvider({ + providerId: "venice", + label: "Venice AI", + choiceId: "venice-api-key", + optionKey: "veniceApiKey", + flagName: "--venice-api-key", + envVar: "VENICE_API_KEY", + promptMessage: "Enter Venice AI API key", + defaultModel: "venice/venice-uncensored", + noteMessage: "Venice is a privacy-focused inference service.", + noteTitle: "Venice AI", + }), + createApiKeyProvider({ + providerId: "vercel-ai-gateway", + label: "AI Gateway API key", + choiceId: "ai-gateway-api-key", + optionKey: "aiGatewayApiKey", + flagName: "--ai-gateway-api-key", + envVar: "AI_GATEWAY_API_KEY", + promptMessage: "Enter AI Gateway API key", + defaultModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", + }), + createApiKeyProvider({ + providerId: "xai", + label: "xAI API key", + choiceId: "xai-api-key", + optionKey: "xaiApiKey", + flagName: "--xai-api-key", + envVar: "XAI_API_KEY", + promptMessage: "Enter xAI API key", + defaultModel: "xai/grok-4", + }), + createApiKeyProvider({ + providerId: "xiaomi", + label: "Xiaomi API key", + choiceId: "xiaomi-api-key", + optionKey: "xiaomiApiKey", + flagName: "--xiaomi-api-key", + envVar: "XIAOMI_API_KEY", + promptMessage: "Enter Xiaomi API key", + defaultModel: "xiaomi/mimo-v2-flash", + }), + { + id: "zai", + label: "Z.AI", + auth: [createZaiMethod("zai-api-key"), createZaiMethod("zai-coding-global")], + }, + { + id: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + auth: [cloudflareAiGatewayMethod], + }, + { + id: "chutes", + label: "Chutes", + auth: [chutesOAuthMethod], + }, + createApiKeyProvider({ + providerId: "kimi", + label: "Kimi Code API key", + choiceId: "kimi-code-api-key", + optionKey: "kimiApiKey", + flagName: "--kimi-api-key", + envVar: "KIMI_API_KEY", + promptMessage: "Enter Kimi Code API key", + defaultModel: "kimi/kimi-k2.5", + expectedProviders: ["kimi", "kimi-code", "kimi-coding"], + }), + createFixedChoiceProvider({ + providerId: "github-copilot", + label: "GitHub Copilot", + choiceId: "github-copilot", + method: { + id: "device", + label: "GitHub device login", + kind: "device_code", + run: async () => ({ profiles: [] }), + }, + }), + ]; } describe("applyAuthChoice", () => { @@ -190,14 +645,12 @@ describe("applyAuthChoice", () => { resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); detectZaiEndpoint.mockReset(); detectZaiEndpoint.mockResolvedValue(null); - setDetectZaiEndpointForTesting(detectZaiEndpoint); loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); await lifecycle.cleanup(); activeStateDir = null; }); - setDetectZaiEndpointForTesting(detectZaiEndpoint); resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); it("does not throw when openai-codex oauth fails", async () => { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ea420bbaf3f..2dc467637c0 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 3da240f2a60..9772c953692 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -42,7 +42,7 @@ async function loadFreshHealthModulesForTest() { recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), updateLastRoute: vi.fn().mockResolvedValue(undefined), })); - vi.doMock("../../extensions/whatsapp/runtime-api.js", () => ({ + vi.doMock("../plugins/runtime/runtime-whatsapp-boundary.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 1234), readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index c86677cfb82..ab3bf7692e7 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -37,54 +37,13 @@ vi.mock("../gateway/call.js", () => ({ randomIdempotencyKey: () => "idem-1", })); -const webAuthExists = vi.hoisted(() => vi.fn(async () => false)); -vi.mock("../../extensions/whatsapp/runtime-api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - webAuthExists, - }; -}); - const handleDiscordAction = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), ); -vi.mock("../../extensions/discord/runtime-api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - handleDiscordAction, - }; -}); - -const handleSlackAction = vi.hoisted(() => - vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), -); -vi.mock("../../extensions/slack/runtime-api.js", () => ({ - handleSlackAction, -})); const handleTelegramAction = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), ); -vi.mock("../../extensions/telegram/test-api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - handleTelegramAction, - }; -}); - -const handleWhatsAppAction = vi.hoisted(() => - vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), -); -vi.mock("../../extensions/whatsapp/runtime-api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - handleWhatsAppAction, - }; -}); let messageCommand: typeof import("./message.js").messageCommand; @@ -103,11 +62,8 @@ beforeEach(() => { testConfig = {}; setActivePluginRegistry(EMPTY_TEST_REGISTRY); callGatewayMock.mockClear(); - webAuthExists.mockClear().mockResolvedValue(false); handleDiscordAction.mockClear(); - handleSlackAction.mockClear(); handleTelegramAction.mockClear(); - handleWhatsAppAction.mockClear(); resolveCommandSecretRefsViaGateway.mockClear(); }); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a6d2ab2f99b..8c69ab99985 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -316,7 +316,7 @@ vi.mock("../channels/plugins/index.js", () => ({ }, ] as unknown, })); -vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ +vi.mock("../plugins/runtime/runtime-whatsapp-boundary.js", () => ({ webAuthExists: mocks.webAuthExists, getWebAuthAgeMs: mocks.getWebAuthAgeMs, readWebSelfId: mocks.readWebSelfId, @@ -404,6 +404,12 @@ vi.mock("../daemon/service.js", () => ({ sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", }), }), + readGatewayServiceState: async () => ({ + installed: true, + loaded: true, + running: true, + runtime: { status: "running", pid: 1234 }, + }), })); vi.mock("../daemon/node-service.js", () => ({ resolveNodeService: () => ({ diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index 34bb6162149..abf18e433f2 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -26,24 +26,6 @@ async function loadExecApprovalSurfaceModule() { getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), })); - vi.doMock("../../extensions/discord/index.js", () => ({ - discordPlugin: {}, - })); - vi.doMock("../../extensions/telegram/index.js", () => ({ - telegramPlugin: {}, - })); - vi.doMock("../../extensions/slack/index.js", () => ({ - slackPlugin: {}, - })); - vi.doMock("../../extensions/whatsapp/index.js", () => ({ - whatsappPlugin: {}, - })); - vi.doMock("../../extensions/signal/index.js", () => ({ - signalPlugin: {}, - })); - vi.doMock("../../extensions/imessage/index.js", () => ({ - imessagePlugin: {}, - })); vi.doMock("../utils/message-channel.js", () => ({ INTERNAL_MESSAGE_CHANNEL: "web", normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), diff --git a/src/infra/matrix-account-selection.test.ts b/src/infra/matrix-account-selection.test.ts index d7f13a7fb9d..7114085a017 100644 --- a/src/infra/matrix-account-selection.test.ts +++ b/src/infra/matrix-account-selection.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { findMatrixAccountEntry, getMatrixScopedEnvVarNames, requiresExplicitMatrixDefaultAccount, resolveConfiguredMatrixAccountIds, resolveMatrixDefaultOrOnlyAccountId, -} from "../../extensions/matrix/runtime-api.js"; -import type { OpenClawConfig } from "../config/config.js"; +} from "../plugin-sdk/matrix.js"; describe("matrix account selection", () => { it("resolves configured account ids from non-canonical account keys", () => { diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index 06fb7cfdd0a..1bc33af98a1 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js"; import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js"; import { diff --git a/src/infra/matrix-migration-snapshot.test.ts b/src/infra/matrix-migration-snapshot.test.ts index 2d0fb850109..d7140cab526 100644 --- a/src/infra/matrix-migration-snapshot.test.ts +++ b/src/infra/matrix-migration-snapshot.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js"; import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; const createBackupArchiveMock = vi.hoisted(() => vi.fn());