perf: avoid heavy reply runtime imports

This commit is contained in:
Peter Steinberger
2026-04-11 01:17:48 +01:00
parent b146c0c26b
commit 776c8e037e
10 changed files with 189 additions and 22 deletions

View File

@@ -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) {

View File

@@ -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),
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Awaited<ReturnType<typeof loadTtsRuntime>>["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<boolean> => {
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,

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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;
}