From 776c8e037ea84018082329aa6a005bcb8a5fd5bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Apr 2026 01:17:48 +0100 Subject: [PATCH] perf: avoid heavy reply runtime imports --- extensions/slack/src/actions.ts | 7 ++- extensions/slack/src/monitor.test-helpers.ts | 1 + .../src/monitor/message-handler/dispatch.ts | 8 +++ ...ispatch-from-config.shared.test-harness.ts | 3 + .../reply/dispatch-from-config.test.ts | 3 + src/auto-reply/reply/dispatch-from-config.ts | 40 ++++++++++--- src/plugin-sdk/channel-reply-pipeline.test.ts | 12 ++++ src/plugin-sdk/channel-reply-pipeline.ts | 25 +++++--- src/tts/tts-config.test.ts | 52 ++++++++++++++++ src/tts/tts-config.ts | 60 ++++++++++++++++++- 10 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 src/tts/tts-config.test.ts diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 73e0e72ccf7..f5013bec3ce 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -65,10 +65,11 @@ function normalizeEmoji(raw: string) { } async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write" = "read") { + if (opts.client) { + return opts.client; + } const token = resolveToken(opts.token, opts.accountId); - return ( - opts.client ?? (mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token)) - ); + return mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token); } async function resolveBotUserId(client: WebClient) { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index add8910f434..9be0005aecd 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -244,6 +244,7 @@ vi.mock("./monitor/conversation.runtime.js", async () => { ...actual, readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args), + recordInboundSession: vi.fn().mockResolvedValue(undefined), upsertChannelPairingRequest: (...args: unknown[]) => slackTestState.upsertPairingRequestMock(...args), }; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index ae73615e48b..28fc3aa7f71 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -22,6 +22,10 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti import { reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; +import { + compileSlackInteractiveReplies, + isSlackInteractiveRepliesEnabled, +} from "../../interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "../../limits.js"; import { recordSlackThreadParticipation } from "../../sent-thread-cache.js"; import { @@ -306,6 +310,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag agentId: route.agentId, channel: "slack", accountId: route.accountId, + transformReplyPayload: (payload) => + isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId }) + ? compileSlackInteractiveReplies(payload) + : payload, typing: { start: async () => { didSetStatus = true; diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 612e65a7cb9..e37f0485904 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -175,6 +175,8 @@ vi.mock("../../logging/diagnostic.js", () => ({ vi.mock("../../config/sessions/thread-info.js", () => ({ parseSessionThreadInfo: (sessionKey: string | undefined) => threadInfoMocks.parseSessionThreadInfo(sessionKey), + parseSessionThreadInfoFast: (sessionKey: string | undefined) => + threadInfoMocks.parseSessionThreadInfo(sessionKey), })); vi.mock("./dispatch-from-config.runtime.js", () => ({ createInternalHookEvent: internalHookMocks.createInternalHookEvent, @@ -279,6 +281,7 @@ vi.mock("./dispatch-acp-session.runtime.js", () => ({ vi.mock("../../tts/tts-config.js", () => ({ normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode, + shouldAttemptTtsPayload: () => true, })); export const noAbortResult = { handled: false, aborted: false } as const; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 18d850c9d5c..052eaa240ea 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -230,6 +230,8 @@ vi.mock("../../logging/diagnostic.js", () => ({ vi.mock("../../config/sessions/thread-info.js", () => ({ parseSessionThreadInfo: (sessionKey: string | undefined) => threadInfoMocks.parseSessionThreadInfo(sessionKey), + parseSessionThreadInfoFast: (sessionKey: string | undefined) => + threadInfoMocks.parseSessionThreadInfo(sessionKey), })); vi.mock("./dispatch-from-config.runtime.js", () => ({ createInternalHookEvent: internalHookMocks.createInternalHookEvent, @@ -342,6 +344,7 @@ vi.mock("./dispatch-acp-session.runtime.js", () => ({ vi.mock("../../tts/tts-config.js", () => ({ normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode, + shouldAttemptTtsPayload: () => true, })); const noAbortResult = { handled: false, aborted: false } as const; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 1cb0c836144..e9deaf1de49 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -7,7 +7,7 @@ import { } from "../../bindings/records.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { parseSessionThreadInfo } from "../../config/sessions/thread-info.js"; +import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; @@ -42,8 +42,12 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; -import { normalizeTtsAutoMode, resolveConfiguredTtsMode } from "../../tts/tts-config.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; +import { + normalizeTtsAutoMode, + resolveConfiguredTtsMode, + shouldAttemptTtsPayload, +} from "../../tts/tts-config.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { FinalizedMsgContext } from "../templating.js"; import { normalizeVerboseLevel } from "../thinking.js"; import { @@ -94,6 +98,9 @@ function loadTtsRuntime() { async function maybeApplyTtsToReplyPayload( params: Parameters>["maybeApplyTtsToPayload"]>[0], ) { + if (!shouldAttemptTtsPayload({ cfg: params.cfg, ttsAuto: params.ttsAuto })) { + return params.payload; + } const { maybeApplyTtsToPayload } = await loadTtsRuntime(); return maybeApplyTtsToPayload(params); } @@ -283,7 +290,7 @@ export async function dispatchReplyFromConfig(params: { // folded back into lastThreadId/deliveryContext during store normalisation and resurrect a // stale route after thread delivery was intentionally cleared. const routeThreadId = - ctx.MessageThreadId ?? parseSessionThreadInfo(acpDispatchSessionKey).threadId; + ctx.MessageThreadId ?? parseSessionThreadInfoFast(acpDispatchSessionKey).threadId; const inboundAudio = isInboundAudioContext(ctx); const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto); const hookRunner = getGlobalHookRunner(); @@ -310,7 +317,22 @@ export async function dispatchReplyFromConfig(params: { // // Debug: `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts` const suppressAcpChildUserDelivery = isParentOwnedBackgroundAcpSession(sessionStoreEntry.entry); - const routeReplyRuntime = await loadRouteReplyRuntime(); + const normalizedOriginatingChannel = normalizeMessageChannel(ctx.OriginatingChannel); + const normalizedProviderChannel = normalizeMessageChannel(ctx.Provider); + const normalizedSurfaceChannel = normalizeMessageChannel(ctx.Surface); + const normalizedCurrentSurface = normalizedProviderChannel ?? normalizedSurfaceChannel; + const isInternalWebchatTurn = + normalizedCurrentSurface === INTERNAL_MESSAGE_CHANNEL && + (normalizedSurfaceChannel === INTERNAL_MESSAGE_CHANNEL || !normalizedSurfaceChannel) && + ctx.ExplicitDeliverRoute !== true; + const hasRouteReplyCandidate = Boolean( + !suppressAcpChildUserDelivery && + !isInternalWebchatTurn && + normalizedOriginatingChannel && + ctx.OriginatingTo && + normalizedOriginatingChannel !== normalizedCurrentSurface, + ); + const routeReplyRuntime = hasRouteReplyCandidate ? await loadRouteReplyRuntime() : undefined; const { originatingChannel, currentSurface, shouldRouteToOriginating, shouldSuppressTyping } = resolveReplyRoutingDecision({ provider: ctx.Provider, @@ -319,7 +341,7 @@ export async function dispatchReplyFromConfig(params: { originatingChannel: ctx.OriginatingChannel, originatingTo: ctx.OriginatingTo, suppressDirectUserDelivery: suppressAcpChildUserDelivery, - isRoutableChannel: routeReplyRuntime.isRoutableChannel, + isRoutableChannel: routeReplyRuntime?.isRoutableChannel ?? (() => false), }); const originatingTo = ctx.OriginatingTo; const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface; @@ -343,7 +365,7 @@ export async function dispatchReplyFromConfig(params: { if (abortSignal?.aborted) { return; } - const result = await routeReplyRuntime.routeReply({ + const result = await routeReplyRuntime!.routeReply({ payload, channel: originatingChannel, to: originatingTo, @@ -370,7 +392,7 @@ export async function dispatchReplyFromConfig(params: { mode: "additive" | "terminal", ): Promise => { if (shouldRouteToOriginating && originatingChannel && originatingTo) { - const result = await routeReplyRuntime.routeReply({ + const result = await routeReplyRuntime!.routeReply({ payload, channel: originatingChannel, to: originatingTo, @@ -1025,7 +1047,7 @@ export async function dispatchReplyFromConfig(params: { audioAsVoice: ttsSyntheticReply.audioAsVoice, }; if (shouldRouteToOriginating && originatingChannel && originatingTo) { - const result = await routeReplyRuntime.routeReply({ + const result = await routeReplyRuntime!.routeReply({ payload: ttsOnlyPayload, channel: originatingChannel, to: originatingTo, diff --git a/src/plugin-sdk/channel-reply-pipeline.test.ts b/src/plugin-sdk/channel-reply-pipeline.test.ts index 68c5ed42d3a..563c611b52c 100644 --- a/src/plugin-sdk/channel-reply-pipeline.test.ts +++ b/src/plugin-sdk/channel-reply-pipeline.test.ts @@ -78,4 +78,16 @@ describe("createChannelReplyPipeline", () => { expect(onReplyStart).toHaveBeenCalledTimes(1); expect(onIdle).toHaveBeenCalledTimes(1); }); + + it("uses an explicit reply transform without resolving the channel plugin", () => { + const transformReplyPayload = vi.fn((payload) => payload); + const pipeline = createChannelReplyPipeline({ + cfg: {}, + agentId: "main", + channel: "slack", + transformReplyPayload, + }); + + expect(pipeline.transformReplyPayload).toBe(transformReplyPayload); + }); }); diff --git a/src/plugin-sdk/channel-reply-pipeline.ts b/src/plugin-sdk/channel-reply-pipeline.ts index 3ffb37ee2cc..1bb647aa3d5 100644 --- a/src/plugin-sdk/channel-reply-pipeline.ts +++ b/src/plugin-sdk/channel-reply-pipeline.ts @@ -29,19 +29,26 @@ export function createChannelReplyPipeline(params: { accountId?: string; typing?: CreateTypingCallbacksParams; typingCallbacks?: TypingCallbacks; + transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }): ChannelReplyPipeline { const channelId = params.channel ? (normalizeChannelId(params.channel) ?? params.channel) : undefined; - const plugin = channelId ? getChannelPlugin(channelId) : undefined; - const transformReplyPayload = plugin?.messaging?.transformReplyPayload - ? (payload: ReplyPayload) => - plugin.messaging?.transformReplyPayload?.({ - payload, - cfg: params.cfg, - accountId: params.accountId, - }) ?? payload - : undefined; + const plugin = params.transformReplyPayload + ? undefined + : channelId + ? getChannelPlugin(channelId) + : undefined; + const transformReplyPayload = + params.transformReplyPayload ?? + (plugin?.messaging?.transformReplyPayload + ? (payload: ReplyPayload) => + plugin.messaging?.transformReplyPayload?.({ + payload, + cfg: params.cfg, + accountId: params.accountId, + }) ?? payload + : undefined); return { ...createReplyPrefixOptions({ cfg: params.cfg, diff --git a/src/tts/tts-config.test.ts b/src/tts/tts-config.test.ts new file mode 100644 index 00000000000..625872c712d --- /dev/null +++ b/src/tts/tts-config.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { shouldAttemptTtsPayload } from "./tts-config.js"; + +describe("shouldAttemptTtsPayload", () => { + let originalPrefsPath: string | undefined; + let dir: string; + let prefsPath: string; + + beforeEach(() => { + originalPrefsPath = process.env.OPENCLAW_TTS_PREFS; + dir = mkdtempSync(path.join(tmpdir(), "openclaw-tts-config-")); + prefsPath = path.join(dir, "tts.json"); + process.env.OPENCLAW_TTS_PREFS = prefsPath; + }); + + afterEach(() => { + if (originalPrefsPath === undefined) { + delete process.env.OPENCLAW_TTS_PREFS; + } else { + process.env.OPENCLAW_TTS_PREFS = originalPrefsPath; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it("skips TTS when config, prefs, and session state leave auto mode off", () => { + expect(shouldAttemptTtsPayload({ cfg: {} as OpenClawConfig })).toBe(false); + }); + + it("honors session auto state before prefs and config", () => { + writeFileSync(prefsPath, JSON.stringify({ tts: { auto: "off" } })); + const cfg = { messages: { tts: { auto: "off" } } } as OpenClawConfig; + + expect(shouldAttemptTtsPayload({ cfg, ttsAuto: "always" })).toBe(true); + expect(shouldAttemptTtsPayload({ cfg, ttsAuto: "off" })).toBe(false); + }); + + it("uses local prefs before config auto mode", () => { + const cfg = { messages: { tts: { auto: "off" } } } as OpenClawConfig; + + writeFileSync(prefsPath, JSON.stringify({ tts: { enabled: true } })); + expect(shouldAttemptTtsPayload({ cfg })).toBe(true); + + writeFileSync(prefsPath, JSON.stringify({ tts: { auto: "off" } })); + expect( + shouldAttemptTtsPayload({ cfg: { messages: { tts: { enabled: true } } } as OpenClawConfig }), + ).toBe(false); + }); +}); diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts index 025a63075fe..2ca892326ed 100644 --- a/src/tts/tts-config.ts +++ b/src/tts/tts-config.ts @@ -1,7 +1,65 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import type { TtsMode } from "../config/types.tts.js"; +import type { TtsAutoMode, TtsMode } from "../config/types.tts.js"; +import { resolveConfigDir, resolveUserPath } from "../utils.js"; +import { normalizeTtsAutoMode } from "./tts-auto-mode.js"; export { normalizeTtsAutoMode } from "./tts-auto-mode.js"; export function resolveConfiguredTtsMode(cfg: OpenClawConfig): TtsMode { return cfg.messages?.tts?.mode ?? "final"; } + +function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { + if (prefsPath?.trim()) { + return resolveUserPath(prefsPath.trim()); + } + const envPath = process.env.OPENCLAW_TTS_PREFS?.trim(); + if (envPath) { + return resolveUserPath(envPath); + } + return path.join(resolveConfigDir(process.env), "settings", "tts.json"); +} + +function readTtsPrefsAutoMode(prefsPath: string): TtsAutoMode | undefined { + try { + if (!existsSync(prefsPath)) { + return undefined; + } + const prefs = JSON.parse(readFileSync(prefsPath, "utf8")) as { + tts?: { auto?: unknown; enabled?: unknown }; + }; + const auto = normalizeTtsAutoMode(prefs.tts?.auto); + if (auto) { + return auto; + } + if (typeof prefs.tts?.enabled === "boolean") { + return prefs.tts.enabled ? "always" : "off"; + } + } catch { + return undefined; + } + return undefined; +} + +export function shouldAttemptTtsPayload(params: { + cfg: OpenClawConfig; + ttsAuto?: string; +}): boolean { + const sessionAuto = normalizeTtsAutoMode(params.ttsAuto); + if (sessionAuto) { + return sessionAuto !== "off"; + } + + const raw = params.cfg.messages?.tts; + const prefsAuto = readTtsPrefsAutoMode(resolveTtsPrefsPathValue(raw?.prefsPath)); + if (prefsAuto) { + return prefsAuto !== "off"; + } + + const configuredAuto = normalizeTtsAutoMode(raw?.auto); + if (configuredAuto) { + return configuredAuto !== "off"; + } + return raw?.enabled === true; +}