From ddd921ff0b411286069815f51325ec4463a6ef88 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 02:21:34 -0400 Subject: [PATCH 1/6] Docs: add new Matrix plugin changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a009e800259..64a463eb8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai - Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. - Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) +- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras. ## 2026.3.13 From b965ef3802d354f936b8f1b5258080c23b51f391 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:47:48 -0500 Subject: [PATCH 2/6] Channels: stabilize lane harness and monitor tests (#50167) * Channels: stabilize lane harness regressions * Signal tests: stabilize tool-result harness dispatch * Telegram tests: harden polling restart assertions * Discord tests: stabilize channel lane harness coverage * Slack tests: align slash harness runtime mocks * Telegram tests: harden dispatch and pairing scenarios * Telegram tests: fix SessionEntry typing in bot callback override case * Slack tests: avoid slash runtime mock deadlock * Tests: address bot review follow-ups * Discord: restore accounts runtime-api seam * Tests: stabilize Discord and Telegram channel harness assertions * Tests: clarify Discord mock seam and remove unused Telegram import * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/discord/src/accounts.ts | 6 +- .../src/monitor.tool-result.test-harness.ts | 66 +-- .../discord/src/monitor/monitor.test.ts | 76 ++-- .../src/monitor.tool-result.test-harness.ts | 69 +++- extensions/slack/src/blocks.test-helpers.ts | 46 ++- extensions/slack/src/monitor.test-helpers.ts | 75 ++-- .../slack/src/monitor/slash.test-harness.ts | 19 +- extensions/slack/src/monitor/slash.test.ts | 21 +- .../src/bot.create-telegram-bot.test.ts | 389 ++++++++++-------- extensions/telegram/src/monitor.test.ts | 41 +- .../src/auto-reply/heartbeat-runner.test.ts | 35 +- extensions/whatsapp/src/test-helpers.ts | 28 +- 13 files changed, 472 insertions(+), 400 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a463eb8ac..dfa7100d461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai - Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob. - WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant. - Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant. +- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant. - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. ### Breaking diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 49193f5fabf..ea28be7fb0d 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,10 +1,8 @@ import { createAccountActionGate, createAccountListHelpers, -} from "openclaw/plugin-sdk/account-helpers"; -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; -import { + normalizeAccountId, + resolveAccountEntry, type OpenClawConfig, type DiscordAccountConfig, type DiscordActionConfig, diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index 1d4bb1d0522..8ce7e8b8309 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -3,58 +3,21 @@ import { vi } from "vitest"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); -export const recordInboundSessionMock: MockFn = vi.fn(); export const updateLastRouteMock: MockFn = vi.fn(); export const dispatchMock: MockFn = vi.fn(); export const readAllowFromStoreMock: MockFn = vi.fn(); export const upsertPairingRequestMock: MockFn = vi.fn(); -vi.mock("./send.js", () => ({ - addRoleDiscord: vi.fn(), - banMemberDiscord: vi.fn(), - createChannelDiscord: vi.fn(), - createScheduledEventDiscord: vi.fn(), - createThreadDiscord: vi.fn(), - deleteChannelDiscord: vi.fn(), - deleteMessageDiscord: vi.fn(), - editChannelDiscord: vi.fn(), - editMessageDiscord: vi.fn(), - fetchChannelInfoDiscord: vi.fn(), - fetchChannelPermissionsDiscord: vi.fn(), - fetchMemberInfoDiscord: vi.fn(), - fetchMessageDiscord: vi.fn(), - fetchReactionsDiscord: vi.fn(), - fetchRoleInfoDiscord: vi.fn(), - fetchVoiceStatusDiscord: vi.fn(), - hasAnyGuildPermissionDiscord: vi.fn(), - kickMemberDiscord: vi.fn(), - listGuildChannelsDiscord: vi.fn(), - listGuildEmojisDiscord: vi.fn(), - listPinsDiscord: vi.fn(), - listScheduledEventsDiscord: vi.fn(), - listThreadsDiscord: vi.fn(), - moveChannelDiscord: vi.fn(), - pinMessageDiscord: vi.fn(), - reactMessageDiscord: async (...args: unknown[]) => { - reactMock(...args); - }, - readMessagesDiscord: vi.fn(), - removeChannelPermissionDiscord: vi.fn(), - removeOwnReactionsDiscord: vi.fn(), - removeReactionDiscord: vi.fn(), - removeRoleDiscord: vi.fn(), - searchMessagesDiscord: vi.fn(), - sendDiscordComponentMessage: vi.fn(), - sendMessageDiscord: (...args: unknown[]) => sendMock(...args), - sendPollDiscord: vi.fn(), - sendStickerDiscord: vi.fn(), - sendVoiceMessageDiscord: vi.fn(), - setChannelPermissionDiscord: vi.fn(), - timeoutMemberDiscord: vi.fn(), - unpinMessageDiscord: vi.fn(), - uploadEmojiDiscord: vi.fn(), - uploadStickerDiscord: vi.fn(), -})); +vi.mock("./send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: (...args: unknown[]) => sendMock(...args), + reactMessageDiscord: async (...args: unknown[]) => { + reactMock(...args); + }, + }; +}); vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -85,19 +48,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), - }; -}); - vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - readSessionUpdatedAt: vi.fn(() => undefined), resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), resolveSessionKey: vi.fn(), diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 7f0dae736d7..158336d2435 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -58,28 +58,29 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; -vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args), + readStoreAllowFromForDmPolicy: async (params: { + provider: string; + accountId: string; + dmPolicy?: string | null; + shouldRead?: boolean | null; + }) => { + if (params.shouldRead === false || params.dmPolicy === "allowlist") { + return []; + } + return await readAllowFromStoreMock(params.provider, params.accountId); + }, }; }); -vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), - }; -}); - -vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, resolvePluginConversationBindingApproval: (...args: unknown[]) => resolvePluginConversationBindingApprovalMock(...args), buildPluginBindingResolvedText: (...args: unknown[]) => @@ -87,14 +88,24 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal }; }); -vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), }; }); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args), + }; +}); + +// agent-components.ts can bind the core dispatcher via reply-runtime re-exports, +// so keep this direct mock to avoid hitting real embedded-agent dispatch in tests. vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => { const actual = await importOriginal< @@ -106,16 +117,16 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import }; }); -vi.mock("../../../../src/channels/session.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), }; }); -vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args), @@ -123,8 +134,8 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { }; }); -vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchPluginInteractiveHandler: (...args: unknown[]) => @@ -189,13 +200,13 @@ describe("agent components", () => { expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledTimes(1); - expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); + const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? ""); + expect(pairingText).toContain("Pairing code:"); + const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1]; + expect(code).toBeDefined(); + expect(pairingText).toContain(`openclaw pairing approve discord ${code}`); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); - expect(readAllowFromStoreMock).toHaveBeenCalledWith({ - provider: "discord", - accountId: "default", - dmPolicy: "pairing", - }); + expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default"); }); it("blocks DM interactions in allowlist mode when sender is not in configured allowFrom", async () => { @@ -229,11 +240,7 @@ describe("agent components", () => { expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalled(); expect(upsertPairingRequestMock).not.toHaveBeenCalled(); - expect(readAllowFromStoreMock).toHaveBeenCalledWith({ - provider: "discord", - accountId: "default", - dmPolicy: "pairing", - }); + expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default"); }); it("allows DM component interactions in open mode without reading pairing store", async () => { @@ -831,10 +838,9 @@ describe("discord component interactions", () => { await button.run(interaction, { cid: "btn_1" } as ComponentData); - expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1); expect(update).toHaveBeenCalledWith({ components: [] }); expect(followUp).toHaveBeenCalledWith({ - content: "Binding approved.", + content: expect.stringContaining("bind approval"), ephemeral: true, }); expect(dispatchReplyMock).not.toHaveBeenCalled(); diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 6995e71320e..7445fc0ffb7 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -1,5 +1,3 @@ -import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime"; -import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; @@ -73,6 +71,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { return { ...actual, loadConfig: () => config, + resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), }; }); @@ -81,28 +83,51 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { return { ...actual, getReplyFromConfig: (...args: unknown[]) => replyMock(...args), + dispatchInboundMessage: async (params: { + ctx: unknown; + cfg: unknown; + dispatcher: { + sendFinalReply: (payload: { text: string }) => boolean; + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + }) => { + const resolved = await replyMock(params.ctx, {}, params.cfg); + const text = typeof resolved?.text === "string" ? resolved.text.trim() : ""; + if (text) { + params.dispatcher.sendFinalReply({ text }); + } + params.dispatcher.markComplete?.(); + await params.dispatcher.waitForIdle?.(); + return { queuedFinal: Boolean(text) }; + }, }; }); -vi.mock("./send.js", () => ({ - sendMessageSignal: (...args: unknown[]) => sendMock(...args), - sendTypingSignal: vi.fn().mockResolvedValue(true), - sendReadReceiptSignal: vi.fn().mockResolvedValue(true), -})); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./send.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + sendMessageSignal: (...args: unknown[]) => sendMock(...args), + sendTypingSignal: vi.fn().mockResolvedValue(true), + sendReadReceiptSignal: vi.fn().mockResolvedValue(true), + }; +}); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args), }; }); @@ -129,7 +154,11 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { }); export function installSignalToolResultTestHooks() { - beforeEach(() => { + beforeEach(async () => { + const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([ + import("openclaw/plugin-sdk/reply-runtime"), + import("openclaw/plugin-sdk/infra-runtime"), + ]); resetInboundDedupe(); config = { messages: { responsePrefix: "PFX" }, diff --git a/extensions/slack/src/blocks.test-helpers.ts b/extensions/slack/src/blocks.test-helpers.ts index ce628d73449..ae5c92818d1 100644 --- a/extensions/slack/src/blocks.test-helpers.ts +++ b/extensions/slack/src/blocks.test-helpers.ts @@ -1,23 +1,6 @@ import type { WebClient } from "@slack/web-api"; import { vi } from "vitest"; -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => ({}), - }; -}); - -vi.mock("./accounts.js", () => ({ - resolveSlackAccount: () => ({ - accountId: "default", - botToken: "xoxb-test", - botTokenSource: "config", - config: {}, - }), -})); - export type SlackEditTestClient = WebClient & { chat: { update: ReturnType; @@ -33,8 +16,35 @@ export type SlackSendTestClient = WebClient & { }; }; +const slackBlockTestState = vi.hoisted(() => ({ + account: { + accountId: "default", + botToken: "xoxb-test", + botTokenSource: "config", + config: {}, + }, + config: {}, +})); + +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => slackBlockTestState.config, + }; +}); + +vi.mock("./accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSlackAccount: () => slackBlockTestState.account, + }; +}); + +// Kept for compatibility with existing tests; mocks install at module evaluation. export function installSlackBlockTestMocks() { - // Backward compatible no-op. Mocks are hoisted at module scope. + return; } export function createSlackEditTestClient(): SlackEditTestClient { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index 87443e5332c..9980c34e29b 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -202,37 +202,30 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); + const replyResolver: typeof actual.getReplyFromConfig = (...args) => + slackTestState.replyMock(...args) as ReturnType; return { ...actual, - dispatchInboundMessage: async (params: { - ctx: unknown; - replyOptions?: { - onReplyStart?: () => Promise | void; - onAssistantMessageStart?: () => Promise | void; - }; - dispatcher: { - sendFinalReply: (payload: unknown) => boolean; - waitForIdle: () => Promise; - markComplete: () => void; - }; - }) => { - const reply = await slackTestState.replyMock(params.ctx, { - ...params.replyOptions, - onReplyStart: - params.replyOptions?.onReplyStart ?? params.replyOptions?.onAssistantMessageStart, - }); - const queuedFinal = reply ? params.dispatcher.sendFinalReply(reply) : false; - params.dispatcher.markComplete(); - await params.dispatcher.waitForIdle(); - return { - queuedFinal, - counts: { - tool: 0, - block: 0, - final: queuedFinal ? 1 : 0, - }, - }; - }, + getReplyFromConfig: replyResolver, + dispatchInboundMessage: (params: Parameters[0]) => + actual.dispatchInboundMessage({ + ...params, + replyResolver, + }), + dispatchInboundMessageWithBufferedDispatcher: ( + params: Parameters[0], + ) => + actual.dispatchInboundMessageWithBufferedDispatcher({ + ...params, + replyResolver, + }), + dispatchInboundMessageWithDispatcher: ( + params: Parameters[0], + ) => + actual.dispatchInboundMessageWithDispatcher({ + ...params, + replyResolver, + }), }; }); @@ -246,9 +239,13 @@ vi.mock("./resolve-users.js", () => ({ entries.map((input) => ({ input, resolved: false })), })); -vi.mock("./send.js", () => ({ - sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), -})); +vi.mock("./send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), + }; +}); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -265,20 +262,12 @@ vi.mock("@slack/bolt", () => { const { handlers, client: slackClient } = ensureSlackTestRuntime(); class App { client = slackClient; - receiver = { - client: { - on: vi.fn(), - off: vi.fn(), - }, - }; event(name: string, handler: SlackHandler) { handlers.set(name, handler); } - command = vi.fn(); - action = vi.fn(); - options = vi.fn(); - view = vi.fn(); - shortcut = vi.fn(); + command() { + /* no-op */ + } start = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined); } diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts index d8f09d74cda..48a11cf3460 100644 --- a/extensions/slack/src/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ resolveAgentRouteMock: vi.fn(), finalizeInboundContextMock: vi.fn(), resolveConversationLabelMock: vi.fn(), - createChannelReplyPipelineMock: vi.fn(), + createReplyPrefixOptionsMock: vi.fn(), recordSessionMetaFromInboundMock: vi.fn(), resolveStorePathMock: vi.fn(), })); @@ -43,27 +43,16 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { return { ...actual, resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args), + createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), recordInboundSessionMetaSafe: (...args: unknown[]) => mocks.recordSessionMetaFromInboundMock(...args), }; }); -vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - createChannelReplyPipeline: (...args: unknown[]) => - mocks.createChannelReplyPipelineMock(...args), - }; -}); - vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - recordSessionMetaFromInbound: (...args: unknown[]) => - mocks.recordSessionMetaFromInboundMock(...args), resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args), }; }); @@ -75,7 +64,7 @@ type SlashHarnessMocks = { resolveAgentRouteMock: ReturnType; finalizeInboundContextMock: ReturnType; resolveConversationLabelMock: ReturnType; - createChannelReplyPipelineMock: ReturnType; + createReplyPrefixOptionsMock: ReturnType; recordSessionMetaFromInboundMock: ReturnType; resolveStorePathMock: ReturnType; }; @@ -95,7 +84,7 @@ export function resetSlackSlashMocks() { }); mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx); mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined); - mocks.createChannelReplyPipelineMock.mockReset().mockReturnValue({ onModelSelected: () => {} }); + mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} }); mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined); mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); } diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index f4cc507c59e..a1f537ffc32 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; -vi.mock("../../../../src/auto-reply/commands-registry.js", () => { +vi.mock("./slash-commands.runtime.js", () => { const usageCommand = { key: "usage", nativeName: "usage" }; const reportCommand = { key: "report", nativeName: "report" }; const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" }; @@ -180,21 +180,26 @@ vi.mock("../../../../src/auto-reply/commands-registry.js", () => { }); type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise; -let registerSlackMonitorSlashCommands: RegisterFn; +let registerSlackMonitorSlashCommandsPromise: Promise | undefined; + +async function loadRegisterSlackMonitorSlashCommands(): Promise { + registerSlackMonitorSlashCommandsPromise ??= import("./slash.js").then((module) => { + const typed = module as unknown as { + registerSlackMonitorSlashCommands: RegisterFn; + }; + return typed.registerSlackMonitorSlashCommands; + }); + return await registerSlackMonitorSlashCommandsPromise; +} const { dispatchMock } = getSlackSlashMocks(); -beforeAll(async () => { - ({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as { - registerSlackMonitorSlashCommands: RegisterFn; - }); -}); - beforeEach(() => { resetSlackSlashMocks(); }); async function registerCommands(ctx: unknown, account: unknown) { + const registerSlackMonitorSlashCommands = await loadRegisterSlackMonitorSlashCommands(); await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never }); } diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 43689ae6b82..5384c93a54f 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -59,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = { textFragmentGapMs: 30, } as const; +async function withIsolatedStateDirAsync(fn: () => Promise): Promise { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-")); + return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + try { + return await fn(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); +} + +async function withConfigPathAsync(cfg: unknown, fn: () => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-")); + const configPath = path.join(dir, "openclaw.json"); + fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8"); + return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => { + try { + return await fn(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + describe("createTelegramBot", () => { beforeAll(() => { process.env.TZ = "UTC"; @@ -250,107 +274,115 @@ describe("createTelegramBot", () => { const cases = [ { name: "new unknown sender", - upsertResults: [{ code: "PAIRME12", created: true }], messages: ["hello"], expectedSendCount: 1, - expectPairingText: true, + pairingUpsertResults: [{ code: "PAIRCODE", created: true }], }, { name: "already pending request", - upsertResults: [ - { code: "PAIRME12", created: true }, - { code: "PAIRME12", created: false }, - ], messages: ["hello", "hello again"], expectedSendCount: 1, - expectPairingText: false, + pairingUpsertResults: [ + { code: "PAIRCODE", created: true }, + { code: "PAIRCODE", created: false }, + ], }, ] as const; - for (const testCase of cases) { - onSpy.mockClear(); - sendMessageSpy.mockClear(); - replySpy.mockClear(); + await withIsolatedStateDirAsync(async () => { + for (const [index, testCase] of cases.entries()) { + onSpy.mockClear(); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockClear(); + let pairingUpsertCall = 0; + upsertChannelPairingRequest.mockImplementation(async () => { + const result = + testCase.pairingUpsertResults[ + Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1) + ]; + pairingUpsertCall += 1; + return result; + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const senderId = Number(`${Date.now()}${index}`.slice(-9)); + for (const text of testCase.messages) { + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text, + date: 1736380800, + from: { id: senderId, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } + + expect(replySpy, testCase.name).not.toHaveBeenCalled(); + expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount); + expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234); + const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); + expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`); + expect(pairingText, testCase.name).toContain("Pairing code:"); + const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1]; + expect(code, testCase.name).toBeDefined(); + expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`); + expect(pairingText, testCase.name).not.toContain(""); + } + }); + }); + it("blocks unauthorized DM media before download and sends pairing reply", async () => { + await withIsolatedStateDirAsync(async () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "pairing" } }, }); readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockClear(); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true }); - for (const result of testCase.upsertResults) { - upsertChannelPairingRequest.mockResolvedValueOnce(result); - } + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const senderId = Number(`${Date.now()}01`.slice(-9)); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - for (const text of testCase.messages) { await handler({ message: { chat: { id: 1234, type: "private" }, - text, + message_id: 410, date: 1736380800, - from: { id: 999, username: "random" }, + photo: [{ file_id: "p1" }], + from: { id: senderId, username: "random" }, }, me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), + getFile: getFileSpy, }); - } - expect(replySpy, testCase.name).not.toHaveBeenCalled(); - expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount); - if (testCase.expectPairingText) { - expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234); - const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); - expect(pairingText, testCase.name).toContain("Your Telegram user id: 999"); - expect(pairingText, testCase.name).toContain("Pairing code:"); - expect(pairingText, testCase.name).toContain("PAIRME12"); - expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12"); - expect(pairingText, testCase.name).not.toContain(""); + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); } - } - }); - it("blocks unauthorized DM media before download and sends pairing reply", async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { - status: 200, - headers: { "content-type": "image/jpeg" }, - }), - ); - const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); - - try { - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - message_id: 410, - date: 1736380800, - photo: [{ file_id: "p1" }], - from: { id: 999, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: getFileSpy, - }); - - expect(getFileSpy).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); - expect(replySpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } }); it("blocks DM media downloads completely when dmPolicy is disabled", async () => { loadConfig.mockReturnValue({ @@ -393,48 +425,51 @@ describe("createTelegramBot", () => { } }); it("blocks unauthorized DM media groups before any photo download", async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { - status: 200, - headers: { "content-type": "image/jpeg" }, - }), - ); - const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); - - try { - createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - message_id: 412, - media_group_id: "dm-album-1", - date: 1736380800, - photo: [{ file_id: "p1" }], - from: { id: 999, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: getFileSpy, + await withIsolatedStateDirAsync(async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const senderId = Number(`${Date.now()}02`.slice(-9)); - expect(getFileSpy).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); - expect(replySpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 412, + media_group_id: "dm-album-1", + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: senderId, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); }); it("triggers typing cue via onReplyStart", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( @@ -851,13 +886,15 @@ describe("createTelegramBot", () => { }); it("routes DMs by telegram accountId binding", async () => { - loadConfig.mockReturnValue({ + const config = { channels: { telegram: { + allowFrom: ["*"], accounts: { opie: { botToken: "tok-opie", dmPolicy: "open", + allowFrom: ["*"], }, }, }, @@ -868,27 +905,30 @@ describe("createTelegramBot", () => { match: { channel: "telegram", accountId: "opie" }, }, ], + }; + loadConfig.mockReturnValue(config); + + await withConfigPathAsync(config, async () => { + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toBe("agent:opie:main"); }); - - createTelegramBot({ token: "tok", accountId: "opie" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "private" }, - from: { id: 999, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:main"); }); it("reloads DM routing bindings between messages without recreating the bot", async () => { @@ -1192,26 +1232,28 @@ describe("createTelegramBot", () => { ]; for (const testCase of cases) { - resetHarnessSpies(); - loadConfig.mockReturnValue(testCase.config); - await dispatchMessage({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, + await withConfigPathAsync(testCase.config, async () => { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 999, username: "testuser" }, + text: testCase.text, + date: 1736380800, + message_id: 42, + message_thread_id: 99, }, - from: { id: 999, username: "testuser" }, - text: testCase.text, - date: 1736380800, - message_id: 42, - message_thread_id: 99, - }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); } }); @@ -1907,7 +1949,7 @@ describe("createTelegramBot", () => { }), "utf-8", ); - loadConfig.mockReturnValue({ + const config = { channels: { telegram: { groupPolicy: "open", @@ -1924,23 +1966,26 @@ describe("createTelegramBot", () => { }, ], session: { store: storePath }, + }; + loadConfig.mockReturnValue(config); + + await withConfigPathAsync(config, async () => { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Routing" }, + from: { id: 999, username: "ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Routing" }, - from: { id: 999, username: "ops" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); }); it("applies topic skill filters and system prompts", async () => { diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 515f9f55b71..d53cf4cffb2 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { tagTelegramNetworkError } from "./network-errors.js"; type MonitorTelegramOpts = import("./monitor.js").MonitorTelegramOpts; @@ -110,7 +109,8 @@ function makeRecoverableFetchError() { }); } -function makeTaggedPollingFetchError() { +async function makeTaggedPollingFetchError() { + const { tagTelegramNetworkError } = await import("./network-errors.js"); const err = makeRecoverableFetchError(); tagTelegramNetworkError(err, { method: "getUpdates", @@ -180,24 +180,41 @@ async function runMonitorAndCaptureStartupOrder(params?: { persistedOffset?: num function mockRunOnceWithStalledPollingRunner(): { stop: ReturnType void | Promise>>; + waitForTaskStart: () => Promise; } { let running = true; let releaseTask: (() => void) | undefined; + let releaseBeforeTaskStart = false; + let signalTaskStarted: (() => void) | undefined; + const taskStarted = new Promise((resolve) => { + signalTaskStarted = resolve; + }); const stop = vi.fn(async () => { running = false; - releaseTask?.(); + if (releaseTask) { + releaseTask(); + return; + } + releaseBeforeTaskStart = true; }); runSpy.mockImplementationOnce(() => makeRunnerStub({ task: () => new Promise((resolve) => { + signalTaskStarted?.(); releaseTask = resolve; + if (releaseBeforeTaskStart) { + resolve(); + } }), stop, isRunning: () => running, }), ); - return { stop }; + return { + stop, + waitForTaskStart: () => taskStarted, + }; } function expectRecoverableRetryState( @@ -533,16 +550,17 @@ describe("monitorTelegramProvider (grammY)", () => { it("force-restarts polling when unhandled network rejection stalls runner", async () => { const { monitorTelegramProvider } = await import("./monitor.js"); const abort = new AbortController(); - const { stop } = mockRunOnceWithStalledPollingRunner(); - mockRunOnceAndAbort(abort); + const firstCycle = mockRunOnceWithStalledPollingRunner(); + mockRunOnceWithStalledPollingRunner(); const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1)); - emitUnhandledRejection(makeTaggedPollingFetchError()); + expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true); + expect(firstCycle.stop).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(2)); + abort.abort(); await monitor; - - expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1); expectRecoverableRetryState(2); }); @@ -578,16 +596,17 @@ describe("monitorTelegramProvider (grammY)", () => { it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => { const { monitorTelegramProvider } = await import("./monitor.js"); const abort = new AbortController(); - const { stop } = mockRunOnceWithStalledPollingRunner(); + const { stop, waitForTaskStart } = mockRunOnceWithStalledPollingRunner(); mockRunOnceAndAbort(abort); const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1)); + await waitForTaskStart(); const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal; expect(firstSignal).toBeInstanceOf(AbortSignal); expect((firstSignal as AbortSignal).aborted).toBe(false); - emitUnhandledRejection(makeTaggedPollingFetchError()); + emitUnhandledRejection(await makeTaggedPollingFetchError()); await monitor; expect((firstSignal as AbortSignal).aborted).toBe(true); diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts index 651074db852..234b4dddfd5 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts @@ -71,12 +71,23 @@ vi.mock("../../../../src/infra/heartbeat-events.js", () => ({ resolveIndicatorType: (status: string) => `indicator:${status}`, })); -vi.mock("../../../../src/logging.js", () => ({ - getChildLogger: () => ({ - info: (...args: unknown[]) => state.loggerInfoCalls.push(args), - warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), - }), -})); +vi.mock("../../../../src/logging.js", async (importOriginal) => { + const actual = await importOriginal(); + const createStubLogger = () => ({ + info: () => undefined, + warn: () => undefined, + error: () => undefined, + child: createStubLogger, + }); + return { + ...actual, + getChildLogger: () => ({ + info: (...args: unknown[]) => state.loggerInfoCalls.push(args), + warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), + }), + createSubsystemLogger: () => createStubLogger(), + }; +}); vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => { const actual = await importOriginal(); @@ -125,10 +136,14 @@ vi.mock("../reconnect.js", () => ({ newConnectionId: () => "run-1", })); -vi.mock("../send.js", () => ({ - sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), - sendReactionWhatsApp: vi.fn(async () => undefined), -})); +vi.mock("../send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), + sendReactionWhatsApp: vi.fn(async () => undefined), + }; +}); vi.mock("../session.js", () => ({ formatError: (err: unknown) => `ERR:${String(err)}`, diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 6ce9a3e3f1c..74c5f8c3584 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -34,15 +34,21 @@ export function resetLoadConfigMock() { vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => { + const mockModule = Object.create(null) as Record; + Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "loadConfig", { + configurable: true, + enumerable: true, + writable: true, + value: () => { const getter = (globalThis as Record)[CONFIG_KEY]; if (typeof getter === "function") { return getter(); } return DEFAULT_CONFIG; }, + }); + Object.assign(mockModule, { updateLastRoute: async (params: { storePath: string; sessionKey: string; @@ -68,7 +74,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { }, recordSessionMetaFromInbound: async () => undefined, resolveStorePath: actual.resolveStorePath, - }; + }); + return mockModule; }); // Some web modules live under `src/web/auto-reply/*` and import config via a different @@ -79,16 +86,21 @@ vi.mock("../../config/config.js", async (importOriginal) => { // For typing in this file (which lives in `src/web/*`), refer to the same module // via the local relative path. const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => { + const mockModule = Object.create(null) as Record; + Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "loadConfig", { + configurable: true, + enumerable: true, + writable: true, + value: () => { const getter = (globalThis as Record)[CONFIG_KEY]; if (typeof getter === "function") { return getter(); } return DEFAULT_CONFIG; }, - }; + }); + return mockModule; }); vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { From bcc725ffe2c3783f4d8fdbf6b7727c357cdd643a Mon Sep 17 00:00:00 2001 From: Shaun Tsai Date: Thu, 19 Mar 2026 15:12:29 +0800 Subject: [PATCH 3/6] fix(agents): strip prompt cache for non-OpenAI responses endpoints (#49877) thanks @ShaunTsai Fixes #48155 Co-authored-by: Shaun Tsai <13811075+ShaunTsai@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> --- CHANGELOG.md | 1 + .../pi-embedded-runner-extraparams.test.ts | 79 +++++++++++++++++++ .../openai-stream-wrappers.ts | 21 ++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa7100d461..c5a376f35bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. +- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 685976bf63d..b176de6fab5 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -2291,4 +2291,83 @@ describe("applyExtraParamsToAgent", () => { expect(run().store).toBe(false); }, ); + + it("strips prompt cache fields for non-OpenAI openai-responses endpoints", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "custom-proxy", + applyModelId: "some-model", + model: { + api: "openai-responses", + provider: "custom-proxy", + id: "some-model", + baseUrl: "https://my-proxy.example.com/v1", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + prompt_cache_key: "session-xyz", + prompt_cache_retention: "24h", + }, + }); + expect(payload).not.toHaveProperty("prompt_cache_key"); + expect(payload).not.toHaveProperty("prompt_cache_retention"); + }); + + it("keeps prompt cache fields for direct OpenAI openai-responses endpoints", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + prompt_cache_key: "session-123", + prompt_cache_retention: "24h", + }, + }); + expect(payload.prompt_cache_key).toBe("session-123"); + expect(payload.prompt_cache_retention).toBe("24h"); + }); + + it("keeps prompt cache fields for direct Azure OpenAI openai-responses endpoints", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "azure-openai-responses", + applyModelId: "gpt-4o", + model: { + api: "openai-responses", + provider: "azure-openai-responses", + id: "gpt-4o", + baseUrl: "https://example.openai.azure.com/openai/v1", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + prompt_cache_key: "session-azure", + prompt_cache_retention: "24h", + }, + }); + expect(payload.prompt_cache_key).toBe("session-azure"); + expect(payload.prompt_cache_retention).toBe("24h"); + }); + + it("keeps prompt cache fields when openai-responses baseUrl is omitted", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + prompt_cache_key: "session-default", + prompt_cache_retention: "24h", + }, + }); + expect(payload.prompt_cache_key).toBe("session-default"); + expect(payload.prompt_cache_retention).toBe("24h"); + }); }); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 4131a33f08d..a4433f65b10 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -154,10 +154,23 @@ function shouldStripResponsesStore( return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false; } +function shouldStripResponsesPromptCache(model: { api?: unknown; baseUrl?: unknown }): boolean { + if (typeof model.api !== "string" || !OPENAI_RESPONSES_APIS.has(model.api)) { + return false; + } + // Missing baseUrl means pi-ai will use the default OpenAI endpoint, so keep + // prompt cache fields for that direct path. + if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) { + return false; + } + return !isDirectOpenAIBaseUrl(model.baseUrl); +} + function applyOpenAIResponsesPayloadOverrides(params: { payloadObj: Record; forceStore: boolean; stripStore: boolean; + stripPromptCache: boolean; useServerCompaction: boolean; compactThreshold: number; }): void { @@ -167,6 +180,10 @@ function applyOpenAIResponsesPayloadOverrides(params: { if (params.stripStore) { delete params.payloadObj.store; } + if (params.stripPromptCache) { + delete params.payloadObj.prompt_cache_key; + delete params.payloadObj.prompt_cache_retention; + } if (params.useServerCompaction && params.payloadObj.context_management === undefined) { params.payloadObj.context_management = [ { @@ -297,7 +314,8 @@ export function createOpenAIResponsesContextManagementWrapper( const forceStore = shouldForceResponsesStore(model); const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams); const stripStore = shouldStripResponsesStore(model, forceStore); - if (!forceStore && !useServerCompaction && !stripStore) { + const stripPromptCache = shouldStripResponsesPromptCache(model); + if (!forceStore && !useServerCompaction && !stripStore && !stripPromptCache) { return underlying(model, context, options); } @@ -313,6 +331,7 @@ export function createOpenAIResponsesContextManagementWrapper( payloadObj: payload as Record, forceStore, stripStore, + stripPromptCache, useServerCompaction, compactThreshold, }); From 22943f24a93adeba55de5327d90764b5f33dab1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Mar 2026 07:16:56 +0000 Subject: [PATCH 4/6] refactor: prune bundled sdk facades --- extensions/copilot-proxy/runtime-api.ts | 7 ++++++- extensions/googlechat/runtime-api.ts | 2 +- extensions/nextcloud-talk/runtime-api.ts | 2 +- extensions/open-prose/runtime-api.ts | 3 ++- extensions/phone-control/runtime-api.ts | 8 +++++++- extensions/talk-voice/api.ts | 3 ++- package.json | 24 ++++-------------------- scripts/lib/plugin-sdk-entrypoints.json | 6 +----- 8 files changed, 24 insertions(+), 31 deletions(-) diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts index 849136c6efb..04c4c25f7d0 100644 --- a/extensions/copilot-proxy/runtime-api.ts +++ b/extensions/copilot-proxy/runtime-api.ts @@ -1 +1,6 @@ -export * from "openclaw/plugin-sdk/copilot-proxy"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthResult, +} from "openclaw/plugin-sdk/core"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 9eecea28139..324abaf11c4 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. // Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface. -export * from "openclaw/plugin-sdk/googlechat"; +export * from "../../src/plugin-sdk/googlechat.js"; diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index fc9283930bd..ba31a546cdf 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/nextcloud-talk"; +export * from "../../src/plugin-sdk/nextcloud-talk.js"; diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts index 1601f81be1f..f2aa0034a22 100644 --- a/extensions/open-prose/runtime-api.ts +++ b/extensions/open-prose/runtime-api.ts @@ -1 +1,2 @@ -export * from "openclaw/plugin-sdk/open-prose"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index 2e9e0adeba2..7db40d08280 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -1 +1,7 @@ -export * from "openclaw/plugin-sdk/phone-control"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { + OpenClawPluginApi, + OpenClawPluginCommandDefinition, + OpenClawPluginService, + PluginCommandContext, +} from "openclaw/plugin-sdk/core"; diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts index a5ae821e944..f2aa0034a22 100644 --- a/extensions/talk-voice/api.ts +++ b/extensions/talk-voice/api.ts @@ -1 +1,2 @@ -export * from "openclaw/plugin-sdk/talk-voice"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; diff --git a/package.json b/package.json index 797142fc574..e70c7dc3061 100644 --- a/package.json +++ b/package.json @@ -185,10 +185,6 @@ "types": "./dist/plugin-sdk/discord-core.d.ts", "default": "./dist/plugin-sdk/discord-core.js" }, - "./plugin-sdk/copilot-proxy": { - "types": "./dist/plugin-sdk/copilot-proxy.d.ts", - "default": "./dist/plugin-sdk/copilot-proxy.js" - }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -245,18 +241,6 @@ "types": "./dist/plugin-sdk/imessage-core.d.ts", "default": "./dist/plugin-sdk/imessage-core.js" }, - "./plugin-sdk/open-prose": { - "types": "./dist/plugin-sdk/open-prose.d.ts", - "default": "./dist/plugin-sdk/open-prose.js" - }, - "./plugin-sdk/phone-control": { - "types": "./dist/plugin-sdk/phone-control.d.ts", - "default": "./dist/plugin-sdk/phone-control.js" - }, - "./plugin-sdk/qwen-portal-auth": { - "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", - "default": "./dist/plugin-sdk/qwen-portal-auth.js" - }, "./plugin-sdk/signal": { "types": "./dist/plugin-sdk/signal.d.ts", "default": "./dist/plugin-sdk/signal.js" @@ -461,6 +445,10 @@ "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" }, + "./plugin-sdk/qwen-portal-auth": { + "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", + "default": "./dist/plugin-sdk/qwen-portal-auth.js" + }, "./plugin-sdk/webhook-ingress": { "types": "./dist/plugin-sdk/webhook-ingress.d.ts", "default": "./dist/plugin-sdk/webhook-ingress.js" @@ -485,10 +473,6 @@ "types": "./dist/plugin-sdk/synology-chat.d.ts", "default": "./dist/plugin-sdk/synology-chat.js" }, - "./plugin-sdk/talk-voice": { - "types": "./dist/plugin-sdk/talk-voice.d.ts", - "default": "./dist/plugin-sdk/talk-voice.js" - }, "./plugin-sdk/thread-ownership": { "types": "./dist/plugin-sdk/thread-ownership.d.ts", "default": "./dist/plugin-sdk/thread-ownership.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index d889433dae8..403f9523f1d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -36,7 +36,6 @@ "telegram-core", "discord", "discord-core", - "copilot-proxy", "feishu", "google", "googlechat", @@ -51,9 +50,6 @@ "slack-core", "imessage", "imessage-core", - "open-prose", - "phone-control", - "qwen-portal-auth", "signal", "whatsapp", "whatsapp-shared", @@ -105,13 +101,13 @@ "secret-input-runtime", "secret-input-schema", "request-url", + "qwen-portal-auth", "webhook-ingress", "webhook-path", "runtime-store", "secret-input", "signal-core", "synology-chat", - "talk-voice", "thread-ownership", "tlon", "twitch", From 0443ee82be776395ae521dc524a53bc94a925547 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 12:49:06 +0530 Subject: [PATCH 5/6] fix(android): auto-connect gateway on app open --- .../java/ai/openclaw/app/MainViewModel.kt | 8 +- .../main/java/ai/openclaw/app/NodeRuntime.kt | 83 ++++++++++--------- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 82fe643314c..0add840cf30 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { fun setForeground(value: Boolean) { foreground = value - runtimeRef.value?.setForeground(value) + val runtime = + if (value && prefs.onboardingCompleted.value) { + ensureRuntime() + } else { + runtimeRef.value + } + runtime?.setForeground(value) } fun setDisplayName(value: String) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 3b37c5b01e2..6dd1b83d3bb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -568,43 +568,8 @@ class NodeRuntime( scope.launch(Dispatchers.Default) { gateways.collect { list -> - if (list.isNotEmpty()) { - // Security: don't let an unauthenticated discovery feed continuously steer autoconnect. - // UX parity with iOS: only set once when unset. - if (lastDiscoveredStableId.value.trim().isEmpty()) { - prefs.setLastDiscoveredStableId(list.first().stableId) - } - } - - if (didAutoConnect) return@collect - if (_isConnected.value) return@collect - - if (manualEnabled.value) { - val host = manualHost.value.trim() - val port = manualPort.value - if (host.isNotEmpty() && port in 1..65535) { - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - if (!manualTls.value) return@collect - val stableId = GatewayEndpoint.manual(host = host, port = port).stableId - val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty() - if (storedFingerprint.isEmpty()) return@collect - - didAutoConnect = true - connect(GatewayEndpoint.manual(host = host, port = port)) - } - return@collect - } - - val targetStableId = lastDiscoveredStableId.value.trim() - if (targetStableId.isEmpty()) return@collect - val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect - - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty() - if (storedFingerprint.isEmpty()) return@collect - - didAutoConnect = true - connect(target) + seedLastDiscoveredGateway(list) + autoConnectIfNeeded() } } @@ -629,11 +594,53 @@ class NodeRuntime( fun setForeground(value: Boolean) { _isForeground.value = value - if (!value) { + if (value) { + reconnectPreferredGatewayOnForeground() + } else { stopActiveVoiceSession() } } + private fun seedLastDiscoveredGateway(list: List) { + if (list.isEmpty()) return + if (lastDiscoveredStableId.value.trim().isNotEmpty()) return + prefs.setLastDiscoveredStableId(list.first().stableId) + } + + private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? { + if (manualEnabled.value) { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isEmpty() || port !in 1..65535) return null + return GatewayEndpoint.manual(host = host, port = port) + } + + val targetStableId = lastDiscoveredStableId.value.trim() + if (targetStableId.isEmpty()) return null + val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null + val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty() + if (storedFingerprint.isEmpty()) return null + return endpoint + } + + private fun autoConnectIfNeeded() { + if (didAutoConnect) return + if (_isConnected.value) return + val endpoint = resolvePreferredGatewayEndpoint() ?: return + didAutoConnect = true + connect(endpoint) + } + + private fun reconnectPreferredGatewayOnForeground() { + if (_isConnected.value) return + if (_pendingGatewayTrust.value != null) return + if (connectedEndpoint != null) { + refreshGatewayConnection() + return + } + resolvePreferredGatewayEndpoint()?.let(::connect) + } + fun setDisplayName(value: String) { prefs.setDisplayName(value) } From f3097b4c09bad44aa83747dd03889a3c2724090c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Mar 2026 07:20:34 +0000 Subject: [PATCH 6/6] refactor: install optional channels for remove --- src/commands/channels.remove.test.ts | 154 +++++++++++++++++++++++++++ src/commands/channels/remove.ts | 29 +++-- 2 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/commands/channels.remove.test.ts diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts new file mode 100644 index 00000000000..1c223d8a75a --- /dev/null +++ b/src/commands/channels.remove.test.ts @@ -0,0 +1,154 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { + ensureChannelSetupPluginInstalled, + loadChannelSetupPluginRegistrySnapshotForChannel, +} from "./channel-setup/plugin-install.js"; +import { configMocks } from "./channels.mock-harness.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const catalogMocks = vi.hoisted(() => ({ + listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), +})); + +vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, + }; +}); + +vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })), + loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()), + }; +}); + +const runtime = createTestRuntime(); +let channelsRemoveCommand: typeof import("./channels.js").channelsRemoveCommand; + +describe("channelsRemoveCommand", () => { + beforeAll(async () => { + ({ channelsRemoveCommand } = await import("./channels.js")); + }); + + beforeEach(() => { + configMocks.readConfigFileSnapshot.mockClear(); + configMocks.writeConfigFile.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + vi.mocked(ensureChannelSetupPluginInstalled).mockClear(); + vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry(), + ); + setActivePluginRegistry(createTestRegistry()); + }); + + it("removes an external channel account after installing its plugin on demand", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + msteams: { + enabled: true, + tenantId: "tenant-1", + }, + }, + }, + }); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + const scopedPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + config: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }).config, + deleteAccount: vi.fn(({ cfg }: { cfg: Record }) => { + const channels = (cfg.channels as Record | undefined) ?? {}; + const nextChannels = { ...channels }; + delete nextChannels.msteams; + return { + ...cfg, + channels: nextChannels, + }; + }), + }, + }; + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) + .mockReturnValueOnce(createTestRegistry()) + .mockReturnValueOnce( + createTestRegistry([ + { + pluginId: "@openclaw/msteams-plugin", + plugin: scopedPlugin, + source: "test", + }, + ]), + ); + + await channelsRemoveCommand( + { + channel: "msteams", + account: "default", + delete: true, + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: catalogEntry, + }), + ); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.not.objectContaining({ + channels: expect.objectContaining({ + msteams: expect.anything(), + }), + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 1cd5fded7d3..f48a85f8521 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -8,6 +8,7 @@ import { type OpenClawConfig, writeConfigFile } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; export type ChannelsRemoveOptions = { @@ -29,14 +30,16 @@ export async function channelsRemoveCommand( runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const loadedCfg = await requireValidConfig(runtime); + if (!loadedCfg) { return; } + let cfg = loadedCfg; const useWizard = shouldUseWizard(params); const prompter = useWizard ? createClackPrompter() : null; - let channel: ChatChannel | null = normalizeChannelId(opts.channel); + const rawChannel = opts.channel?.trim() ?? ""; + let channel: ChatChannel | null = normalizeChannelId(rawChannel); let accountId = normalizeAccountId(opts.account); const deleteConfig = Boolean(opts.delete); @@ -73,15 +76,16 @@ export async function channelsRemoveCommand( return; } } else { - if (!channel) { + if (!rawChannel) { runtime.error("Channel is required. Use --channel ."); runtime.exit(1); return; } if (!deleteConfig) { const confirm = createClackPrompter(); + const channelPromptLabel = channel ? channelLabel(channel) : rawChannel; const ok = await confirm.confirm({ - message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`, + message: `Disable ${channelPromptLabel} account "${accountId}"? (keeps config)`, initialValue: true, }); if (!ok) { @@ -90,7 +94,20 @@ export async function channelsRemoveCommand( } } - const plugin = getChannelPlugin(channel); + const resolvedPluginState = + !useWizard && rawChannel + ? await resolveInstallableChannelPlugin({ + cfg, + runtime, + rawChannel, + allowInstall: true, + }) + : null; + if (resolvedPluginState?.configChanged) { + cfg = resolvedPluginState.cfg; + } + channel = resolvedPluginState?.channelId ?? channel; + const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined); if (!plugin) { runtime.error(`Unknown channel: ${channel}`); runtime.exit(1);