From 47c020bfc4c086dcd906bb695919881239aa8cc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 02:10:49 +0100 Subject: [PATCH] fix: process tts in cron announce delivery --- CHANGELOG.md | 1 + .../delivery-dispatch.double-announce.test.ts | 71 +++++++++++++++++-- src/cron/isolated-agent/delivery-dispatch.ts | 59 ++++++++++++++- src/cron/isolated-agent/run.ts | 1 + 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 736c71351aa..03607cd2dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash. +- Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000. - Doctor/WhatsApp: warn when Linux crontabs still run the legacy `ensure-whatsapp.sh` health check, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe. - Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis. - Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail. diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 526a6ebdf24..ccd21e8ecb4 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -15,10 +15,12 @@ import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; // --- Module mocks (must be hoisted before imports) --- -const { countActiveDescendantRunsMock, retireSessionMcpRuntimeMock } = vi.hoisted(() => ({ - countActiveDescendantRunsMock: vi.fn().mockReturnValue(0), - retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true), -})); +const { countActiveDescendantRunsMock, maybeApplyTtsToPayloadMock, retireSessionMcpRuntimeMock } = + vi.hoisted(() => ({ + countActiveDescendantRunsMock: vi.fn().mockReturnValue(0), + maybeApplyTtsToPayloadMock: vi.fn(async (params: { payload: unknown }) => params.payload), + retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true), + })); vi.mock("../../config/sessions/main-session.js", () => ({ resolveAgentMainSessionKey: vi.fn(({ agentId }: { agentId: string }) => `agent:${agentId}:main`), @@ -66,6 +68,10 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); +vi.mock("../../tts/tts.runtime.js", () => ({ + maybeApplyTtsToPayload: maybeApplyTtsToPayloadMock, +})); + vi.mock("./subagent-followup-hints.js", () => ({ expectsSubagentFollowup: vi.fn().mockReturnValue(false), isLikelyInterimCronMessage: vi.fn().mockReturnValue(false), @@ -181,6 +187,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); vi.mocked(retireSessionMcpRuntime).mockResolvedValue(true); + maybeApplyTtsToPayloadMock.mockReset().mockImplementation(async (params) => params.payload); }); afterEach(() => { @@ -336,6 +343,62 @@ describe("dispatchCronDelivery — double-announce guard", () => { ).toBe(false); }); + it("applies TTS directives before direct cron announce delivery", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + maybeApplyTtsToPayloadMock.mockImplementation(async (params: { payload: unknown }) => { + const payload = params.payload as { text?: string }; + expect(payload.text).toBe("[[tts]] Morning briefing complete."); + return { + text: "Morning briefing complete.", + mediaUrl: "file:///tmp/cron-tts.mp3", + audioAsVoice: true, + spokenText: "Morning briefing complete.", + }; + }); + + const params = makeBaseParams({ + synthesizedText: "[[tts]] Morning briefing complete.", + runStartedAt: 1_000, + }); + params.cfgWithAgentDefaults = { + messages: { + tts: { + auto: "tagged", + provider: "microsoft", + }, + }, + } as never; + + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(maybeApplyTtsToPayloadMock).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: params.cfgWithAgentDefaults, + channel: "telegram", + kind: "final", + agentId: "main", + accountId: undefined, + }), + ); + expect(deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123456", + payloads: [ + { + text: "Morning briefing complete.", + mediaUrl: "file:///tmp/cron-tts.mp3", + audioAsVoice: true, + spokenText: "Morning briefing complete.", + }, + ], + }), + ); + }); + it("preserves all successful text payloads for direct delivery", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 2ec7fa7e642..e07dcbeb72f 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -13,6 +13,7 @@ import { resolveMainSessionKey, } from "../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { TtsAutoMode } from "../../config/types.tts.js"; import { sleepWithAbort } from "../../infra/backoff.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; @@ -24,6 +25,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { shouldAttemptTtsPayload } from "../../tts/tts-config.js"; import { createCronExecutionId } from "../run-id.js"; import { hasScheduledNextRunAtMs } from "../service/jobs.js"; import type { CronJob, CronRunTelemetry } from "../types.js"; @@ -119,6 +121,7 @@ type DispatchCronDeliveryParams = { deliveryPayloadHasStructuredContent: boolean; deliveryPayloads: ReplyPayload[]; synthesizedText?: string; + ttsAuto?: TtsAutoMode; summary?: string; outputText?: string; telemetry?: CronRunTelemetry; @@ -183,6 +186,7 @@ let deliveryLoggerRuntimePromise: let subagentFollowupRuntimePromise: | Promise | undefined; +let ttsRuntimePromise: Promise | undefined; const COMPLETED_DIRECT_CRON_DELIVERIES = new Map(); @@ -217,6 +221,11 @@ async function loadSubagentFollowupRuntime(): Promise< return await subagentFollowupRuntimePromise; } +async function loadTtsRuntime(): Promise { + ttsRuntimePromise ??= import("../../tts/tts.runtime.js"); + return await ttsRuntimePromise; +} + async function logCronDeliveryWarn(message: string): Promise { const { logWarn } = await loadDeliveryLoggerRuntime(); logWarn(message); @@ -303,6 +312,40 @@ function getCompletedDirectCronDelivery( return cloneDeliveryResults(cached.results); } +async function maybeApplyTtsToCronPayloads(params: { + cfg: OpenClawConfig; + payloads: ReplyPayload[]; + delivery: SuccessfulDeliveryTarget; + agentId: string; + ttsAuto?: TtsAutoMode; +}): Promise { + if ( + !shouldAttemptTtsPayload({ + cfg: params.cfg, + ttsAuto: params.ttsAuto, + agentId: params.agentId, + channelId: params.delivery.channel, + accountId: params.delivery.accountId, + }) + ) { + return params.payloads; + } + const { maybeApplyTtsToPayload } = await loadTtsRuntime(); + return await Promise.all( + params.payloads.map((payload) => + maybeApplyTtsToPayload({ + payload, + cfg: params.cfg, + channel: params.delivery.channel, + kind: "final", + ttsAuto: params.ttsAuto, + agentId: params.agentId, + accountId: params.delivery.accountId, + }), + ), + ); +} + function buildDirectCronDeliveryIdempotencyKey(params: { jobId: string; runStartedAt: number; @@ -524,7 +567,7 @@ export async function dispatchCronDelivery( : synthesizedText ? [{ text: synthesizedText }] : []; - const payloadsForDelivery = rawPayloads + const normalizedPayloads = rawPayloads .map((p) => { if (!p.text) { return p; @@ -535,7 +578,7 @@ export async function dispatchCronDelivery( }); }) .filter((p) => hasReplyPayloadContent(p, { trimText: true })); - if (payloadsForDelivery.length === 0) { + if (normalizedPayloads.length === 0) { return await finishSilentReplyDelivery(); } if (params.isAborted()) { @@ -575,6 +618,18 @@ export async function dispatchCronDelivery( ...params.telemetry, }); } + const payloadsForDelivery = ( + await maybeApplyTtsToCronPayloads({ + cfg: params.cfgWithAgentDefaults, + payloads: normalizedPayloads, + delivery, + agentId: params.agentId, + ttsAuto: params.ttsAuto, + }) + ).filter((p) => hasReplyPayloadContent(p, { trimText: true })); + if (payloadsForDelivery.length === 0) { + return await finishSilentReplyDelivery(); + } deliveryAttempted = true; const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey); if (cachedResults) { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 1e83a15314a..504949553df 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -958,6 +958,7 @@ async function finalizeCronRun(params: { deliveryPayloadHasStructuredContent, deliveryPayloads, synthesizedText, + ttsAuto: prepared.cronSession.sessionEntry.ttsAuto, summary, outputText, telemetry,