perf(inbound): trim dispatch and command startup imports (#52374)

* perf(inbound): trim dispatch and command startup imports

* fix(reply): restore command alias canonicalization

* style(reply): format command context

* fix(reply): restore runtime shim exports

* test(reply): mock ACP route seam

* fix(reply): repair dispatch type seams
This commit is contained in:
Vincent Koc
2026-03-22 13:19:57 -07:00
committed by GitHub
parent 3025760867
commit 5369ea53be
12 changed files with 234 additions and 131 deletions

View File

@@ -0,0 +1 @@
export { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildCommandContext } from "./commands-context.js";
import { buildTestCtx } from "./test-ctx.js";
describe("buildCommandContext", () => {
it("canonicalizes registered aliases like /id to their primary command", () => {
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
From: "user",
To: "bot",
Body: "/id",
RawBody: "/id",
CommandBody: "/id",
BodyForCommands: "/id",
});
const result = buildCommandContext({
ctx,
cfg: {} as OpenClawConfig,
isGroup: false,
triggerBodyNormalized: "/id",
commandAuthorized: true,
});
expect(result.commandBodyNormalized).toBe("/whoami");
});
});

View File

@@ -1,39 +1,10 @@
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
import { normalizeCommandBody } from "../commands-registry.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import type { CommandContext } from "./commands-types.js"; import type { CommandContext } from "./commands-types.js";
import { stripMentions } from "./mentions.js"; import { stripMentions } from "./mentions.js";
function normalizeCommandBodyLite(raw: string, botUsername?: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) {
return trimmed;
}
const newline = trimmed.indexOf("\n");
const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: singleLine;
const normalizedBotUsername = botUsername?.trim().toLowerCase();
const mentionMatch = normalizedBotUsername
? normalized.match(/^\/([^\s@]+)@([^\s]+)(.*)$/)
: null;
const mentionNormalized =
mentionMatch && mentionMatch[2].toLowerCase() === normalizedBotUsername
? `/${mentionMatch[1]}${mentionMatch[3] ?? ""}`
: normalized;
return mentionNormalized.replace(/^\/([^\s]+)(.*)$/, (_, command: string, rest: string) => {
return `/${command.toLowerCase()}${rest ?? ""}`;
});
}
export function buildCommandContext(params: { export function buildCommandContext(params: {
ctx: MsgContext; ctx: MsgContext;
cfg: OpenClawConfig; cfg: OpenClawConfig;
@@ -53,9 +24,9 @@ export function buildCommandContext(params: {
const channel = (ctx.Provider ?? surface).trim().toLowerCase(); const channel = (ctx.Provider ?? surface).trim().toLowerCase();
const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized; const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBodyLite( const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized, isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
ctx.BotUsername, { botUsername: ctx.BotUsername },
); );
return { return {

View File

@@ -6,44 +6,26 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isAcpSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { isAcpSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js"; import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAcpCommand } from "./commands-acp.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js"; import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleBtwCommand } from "./commands-btw.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import {
handleCommandsListCommand,
handleContextCommand,
handleExportSessionCommand,
handleHelpCommand,
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import { handlePluginsCommand } from "./commands-plugins.js";
import {
handleAbortTrigger,
handleActivationCommand,
handleFastCommand,
handleRestartCommand,
handleSessionCommand,
handleSendPolicyCommand,
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleTtsCommands } from "./commands-tts.js";
import type { import type {
CommandHandler, CommandHandler,
CommandHandlerResult, CommandHandlerResult,
HandleCommandsParams, HandleCommandsParams,
} from "./commands-types.js"; } from "./commands-types.js";
import { routeReply } from "./route-reply.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
let commandHandlersRuntimePromise: Promise<typeof import("./commands-handlers.runtime.js")> | null =
null;
function loadRouteReplyRuntime() {
routeReplyRuntimePromise ??= import("./route-reply.runtime.js");
return routeReplyRuntimePromise;
}
function loadCommandHandlersRuntime() {
commandHandlersRuntimePromise ??= import("./commands-handlers.runtime.js");
return commandHandlersRuntimePromise;
}
let HANDLERS: CommandHandler[] | null = null; let HANDLERS: CommandHandler[] | null = null;
@@ -82,6 +64,7 @@ export async function emitResetCommandHooks(params: {
const to = params.ctx.OriginatingTo || params.command.from || params.command.to; const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) { if (channel && to) {
const { routeReply } = await loadRouteReplyRuntime();
const hookReply = { text: hookEvent.messages.join("\n\n") }; const hookReply = { text: hookEvent.messages.join("\n\n") };
await routeReply({ await routeReply({
payload: hookReply, payload: hookReply,
@@ -174,37 +157,7 @@ function resolveSessionEntryForHookSessionKey(
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> { export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) { if (HANDLERS === null) {
HANDLERS = [ HANDLERS = (await loadCommandHandlersRuntime()).loadCommandHandlers();
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBtwCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
handleFastCommand,
handleUsageCommand,
handleSessionCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,
handleExportSessionCommand,
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleMcpCommand,
handlePluginsCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,
handleStopCommand,
handleCompactCommand,
handleAbortTrigger,
];
} }
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
const resetRequested = Boolean(resetMatch); const resetRequested = Boolean(resetMatch);

View File

@@ -0,0 +1,65 @@
import { handleAcpCommand } from "./commands-acp.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleBtwCommand } from "./commands-btw.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import {
handleCommandsListCommand,
handleContextCommand,
handleExportSessionCommand,
handleHelpCommand,
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import { handlePluginsCommand } from "./commands-plugins.js";
import {
handleAbortTrigger,
handleActivationCommand,
handleFastCommand,
handleRestartCommand,
handleSendPolicyCommand,
handleSessionCommand,
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleTtsCommands } from "./commands-tts.js";
import type { CommandHandler } from "./commands-types.js";
export function loadCommandHandlers(): CommandHandler[] {
return [
handlePluginCommand,
handleBtwCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
handleFastCommand,
handleUsageCommand,
handleSessionCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,
handleExportSessionCommand,
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleMcpCommand,
handlePluginsCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,
handleStopCommand,
handleCompactCommand,
handleAbortTrigger,
];
}

View File

@@ -0,0 +1 @@
export { shouldBypassAcpDispatchForCommand, tryDispatchAcpReply } from "./dispatch-acp.js";

View File

@@ -104,6 +104,24 @@ const ttsMocks = vi.hoisted(() => {
}; };
}); });
vi.mock("./route-reply.runtime.js", () => ({
isRoutableChannel: (channel: string | undefined) =>
Boolean(
channel &&
[
"telegram",
"slack",
"discord",
"signal",
"imessage",
"whatsapp",
"feishu",
"mattermost",
].includes(channel),
),
routeReply: mocks.routeReply,
}));
vi.mock("./route-reply.js", () => ({ vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) => isRoutableChannel: (channel: string | undefined) =>
Boolean( Boolean(
@@ -122,7 +140,7 @@ vi.mock("./route-reply.js", () => ({
routeReply: mocks.routeReply, routeReply: mocks.routeReply,
})); }));
vi.mock("./abort.js", () => ({ vi.mock("./abort.runtime.js", () => ({
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage, tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
formatAbortReplyText: (stoppedSubagents?: number) => { formatAbortReplyText: (stoppedSubagents?: number) => {
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
@@ -138,15 +156,21 @@ vi.mock("../../logging/diagnostic.js", () => ({
logMessageProcessed: diagnosticMocks.logMessageProcessed, logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange, logSessionStateChange: diagnosticMocks.logSessionStateChange,
})); }));
vi.mock("../../config/sessions.js", async (importOriginal) => { vi.mock("../../config/sessions/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions.js")>(); const actual = await importOriginal<typeof import("../../config/sessions/store.js")>();
return { return {
...actual, ...actual,
loadSessionStore: sessionStoreMocks.loadSessionStore, loadSessionStore: sessionStoreMocks.loadSessionStore,
resolveStorePath: sessionStoreMocks.resolveStorePath,
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
}; };
}); });
vi.mock("../../config/sessions/paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions/paths.js")>();
return {
...actual,
resolveStorePath: sessionStoreMocks.resolveStorePath,
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({ vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner, getGlobalHookRunner: () => hookMocks.runner,
@@ -192,6 +216,13 @@ vi.mock("../../tts/tts.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg), resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
})); }));
vi.mock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("../../tts/tts-config.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode,
}));
const noAbortResult = { handled: false, aborted: false } as const; const noAbortResult = { handled: false, aborted: false } as const;
const emptyConfig = {} as OpenClawConfig; const emptyConfig = {} as OpenClawConfig;

View File

@@ -6,13 +6,10 @@ import {
} from "../../bindings/records.js"; } from "../../bindings/records.js";
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { import { parseSessionThreadInfo } from "../../config/sessions/delivery-info.js";
loadSessionStore, import { resolveStorePath } from "../../config/sessions/paths.js";
parseSessionThreadInfo, import { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js";
resolveSessionStoreEntry, import type { SessionEntry } from "../../config/sessions/types.js";
resolveStorePath,
type SessionEntry,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
@@ -41,19 +38,47 @@ import {
} from "../../plugins/conversation-binding.js"; } from "../../plugins/conversation-binding.js";
import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; import { normalizeTtsAutoMode, resolveConfiguredTtsMode } from "../../tts/tts-config.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js"; import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { BlockReplyContext, GetReplyOptions, ReplyPayload } from "../types.js";
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldBypassAcpDispatchForCommand, tryDispatchAcpReply } from "./dispatch-acp.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { resolveRunTypingPolicy } from "./typing-policy.js"; import { resolveRunTypingPolicy } from "./typing-policy.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
let getReplyFromConfigRuntimePromise: Promise<
typeof import("./get-reply-from-config.runtime.js")
> | null = null;
let abortRuntimePromise: Promise<typeof import("./abort.runtime.js")> | null = null;
let dispatchAcpRuntimePromise: Promise<typeof import("./dispatch-acp.runtime.js")> | null = null;
let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | null = null;
function loadRouteReplyRuntime() {
routeReplyRuntimePromise ??= import("./route-reply.runtime.js");
return routeReplyRuntimePromise;
}
function loadGetReplyFromConfigRuntime() {
getReplyFromConfigRuntimePromise ??= import("./get-reply-from-config.runtime.js");
return getReplyFromConfigRuntimePromise;
}
function loadAbortRuntime() {
abortRuntimePromise ??= import("./abort.runtime.js");
return abortRuntimePromise;
}
function loadDispatchAcpRuntime() {
dispatchAcpRuntimePromise ??= import("./dispatch-acp.runtime.js");
return dispatchAcpRuntimePromise;
}
function loadTtsRuntime() {
ttsRuntimePromise ??= import("../../tts/tts.runtime.js");
return ttsRuntimePromise;
}
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i; const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i; const AUDIO_HEADER_RE = /^\[Audio\b/i;
const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase(); const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase();
@@ -126,7 +151,7 @@ export async function dispatchReplyFromConfig(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
dispatcher: ReplyDispatcher; dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">; replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof getReplyFromConfig; replyResolver?: typeof import("./get-reply-from-config.runtime.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchFromConfigResult> {
const { ctx, cfg, dispatcher } = params; const { ctx, cfg, dispatcher } = params;
const diagnosticsEnabled = isDiagnosticsEnabled(cfg); const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
@@ -230,9 +255,10 @@ export async function dispatchReplyFromConfig(params: {
currentSurface === INTERNAL_MESSAGE_CHANNEL && currentSurface === INTERNAL_MESSAGE_CHANNEL &&
(surfaceChannel === INTERNAL_MESSAGE_CHANNEL || !surfaceChannel) && (surfaceChannel === INTERNAL_MESSAGE_CHANNEL || !surfaceChannel) &&
ctx.ExplicitDeliverRoute !== true; ctx.ExplicitDeliverRoute !== true;
const routeReplyRuntime = await loadRouteReplyRuntime();
const shouldRouteToOriginating = Boolean( const shouldRouteToOriginating = Boolean(
!isInternalWebchatTurn && !isInternalWebchatTurn &&
isRoutableChannel(originatingChannel) && routeReplyRuntime.isRoutableChannel(originatingChannel) &&
originatingTo && originatingTo &&
originatingChannel !== currentSurface, originatingChannel !== currentSurface,
); );
@@ -259,7 +285,7 @@ export async function dispatchReplyFromConfig(params: {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
return; return;
} }
const result = await routeReply({ const result = await routeReplyRuntime.routeReply({
payload, payload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
@@ -282,7 +308,7 @@ export async function dispatchReplyFromConfig(params: {
mode: "additive" | "terminal", mode: "additive" | "terminal",
): Promise<boolean> => { ): Promise<boolean> => {
if (shouldRouteToOriginating && originatingChannel && originatingTo) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({ const result = await routeReplyRuntime.routeReply({
payload, payload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
@@ -418,15 +444,16 @@ export async function dispatchReplyFromConfig(params: {
markProcessing(); markProcessing();
try { try {
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); const abortRuntime = await loadAbortRuntime();
const fastAbort = await abortRuntime.tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) { if (fastAbort.handled) {
const payload = { const payload = {
text: formatAbortReplyText(fastAbort.stoppedSubagents), text: abortRuntime.formatAbortReplyText(fastAbort.stoppedSubagents),
} satisfies ReplyPayload; } satisfies ReplyPayload;
let queuedFinal = false; let queuedFinal = false;
let routedFinalCount = 0; let routedFinalCount = 0;
if (shouldRouteToOriginating && originatingChannel && originatingTo) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({ const result = await routeReplyRuntime.routeReply({
payload, payload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
@@ -456,7 +483,8 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts }; return { queuedFinal, counts };
} }
const bypassAcpForCommand = shouldBypassAcpDispatchForCommand(ctx, cfg); const dispatchAcpRuntime = await loadDispatchAcpRuntime();
const bypassAcpForCommand = dispatchAcpRuntime.shouldBypassAcpDispatchForCommand(ctx, cfg);
const sendPolicy = resolveSendPolicy({ const sendPolicy = resolveSendPolicy({
cfg, cfg,
@@ -481,7 +509,7 @@ export async function dispatchReplyFromConfig(params: {
} }
const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native"; const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native";
const acpDispatch = await tryDispatchAcpReply({ const acpDispatch = await dispatchAcpRuntime.tryDispatchAcpReply({
ctx, ctx,
cfg, cfg,
dispatcher, dispatcher,
@@ -508,6 +536,7 @@ export async function dispatchReplyFromConfig(params: {
// TTS audio separately from the accumulated block content. // TTS audio separately from the accumulated block content.
let accumulatedBlockText = ""; let accumulatedBlockText = "";
let blockCount = 0; let blockCount = 0;
const { maybeApplyTtsToPayload } = await loadTtsRuntime();
const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => {
if ( if (
@@ -547,7 +576,9 @@ export async function dispatchReplyFromConfig(params: {
systemEvent: shouldRouteToOriginating, systemEvent: shouldRouteToOriginating,
}); });
const replyResult = await (params.replyResolver ?? getReplyFromConfig)( const replyResolver =
params.replyResolver ?? (await loadGetReplyFromConfigRuntime()).getReplyFromConfig;
const replyResult = await replyResolver(
ctx, ctx,
{ {
...params.replyOptions, ...params.replyOptions,
@@ -575,12 +606,12 @@ export async function dispatchReplyFromConfig(params: {
}; };
return run(); return run();
}, },
onBlockReply: (payload: ReplyPayload, context) => { onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
const run = async () => { const run = async () => {
// Suppress reasoning payloads — channels using this generic dispatch // Suppress reasoning payloads — channels using this generic dispatch
// path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane.
// Telegram has its own dispatch path that handles reasoning splitting. // Telegram has its own dispatch path that handles reasoning splitting.
if (shouldSuppressReasoningPayload(payload)) { if (payload.isReasoning === true) {
return; return;
} }
// Accumulate block text for TTS generation after streaming. // Accumulate block text for TTS generation after streaming.
@@ -617,7 +648,7 @@ export async function dispatchReplyFromConfig(params: {
// Command handling prepared a trailing prompt after ACP in-place reset. // Command handling prepared a trailing prompt after ACP in-place reset.
// Route that tail through ACP now (same turn) instead of embedded dispatch. // Route that tail through ACP now (same turn) instead of embedded dispatch.
ctx.AcpDispatchTailAfterReset = false; ctx.AcpDispatchTailAfterReset = false;
const acpTailDispatch = await tryDispatchAcpReply({ const acpTailDispatch = await dispatchAcpRuntime.tryDispatchAcpReply({
ctx, ctx,
cfg, cfg,
dispatcher, dispatcher,
@@ -647,7 +678,7 @@ export async function dispatchReplyFromConfig(params: {
for (const reply of replies) { for (const reply of replies) {
// Suppress reasoning payloads from channel delivery — channels using this // Suppress reasoning payloads from channel delivery — channels using this
// generic dispatch path do not have a dedicated reasoning lane. // generic dispatch path do not have a dedicated reasoning lane.
if (shouldSuppressReasoningPayload(reply)) { if (reply.isReasoning === true) {
continue; continue;
} }
const ttsReply = await maybeApplyTtsToPayload({ const ttsReply = await maybeApplyTtsToPayload({
@@ -660,7 +691,7 @@ export async function dispatchReplyFromConfig(params: {
}); });
if (shouldRouteToOriginating && originatingChannel && originatingTo) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel. // Route final reply to originating channel.
const result = await routeReply({ const result = await routeReplyRuntime.routeReply({
payload: ttsReply, payload: ttsReply,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
@@ -685,7 +716,7 @@ export async function dispatchReplyFromConfig(params: {
} }
} }
const ttsMode = resolveTtsConfig(cfg).mode ?? "final"; const ttsMode = resolveConfiguredTtsMode(cfg);
// Generate TTS-only reply after block streaming completes (when there's no final reply). // Generate TTS-only reply after block streaming completes (when there's no final reply).
// This handles the case where block streaming succeeds and drops final payloads, // This handles the case where block streaming succeeds and drops final payloads,
// but we still want TTS audio to be generated from the accumulated block content. // but we still want TTS audio to be generated from the accumulated block content.
@@ -712,7 +743,7 @@ export async function dispatchReplyFromConfig(params: {
audioAsVoice: ttsSyntheticReply.audioAsVoice, audioAsVoice: ttsSyntheticReply.audioAsVoice,
}; };
if (shouldRouteToOriginating && originatingChannel && originatingTo) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({ const result = await routeReplyRuntime.routeReply({
payload: ttsOnlyPayload, payload: ttsOnlyPayload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,

View File

@@ -0,0 +1 @@
export { getReplyFromConfig } from "../reply.js";

View File

@@ -1 +1 @@
export { routeReply } from "./route-reply.js"; export { isRoutableChannel, routeReply } from "./route-reply.js";

19
src/tts/tts-config.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { OpenClawConfig } from "../config/config.js";
import type { TtsAutoMode, TtsMode } from "../config/types.tts.js";
const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]);
export function normalizeTtsAutoMode(value: unknown): TtsAutoMode | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (TTS_AUTO_MODES.has(normalized as TtsAutoMode)) {
return normalized as TtsAutoMode;
}
return undefined;
}
export function resolveConfiguredTtsMode(cfg: OpenClawConfig): TtsMode {
return cfg.messages?.tts?.mode ?? "final";
}

1
src/tts/tts.runtime.ts Normal file
View File

@@ -0,0 +1 @@
export { maybeApplyTtsToPayload } from "./tts.js";