Skip to content

Commit 47c020b

Browse files
committed
fix: process tts in cron announce delivery
1 parent cac35db commit 47c020b

4 files changed

Lines changed: 126 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
### Fixes
1717

1818
- 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.
19+
- 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.
1920
- 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.
2021
- 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.
2122
- 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.

src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
1515

1616
// --- Module mocks (must be hoisted before imports) ---
1717

18-
const { countActiveDescendantRunsMock, retireSessionMcpRuntimeMock } = vi.hoisted(() => ({
19-
countActiveDescendantRunsMock: vi.fn().mockReturnValue(0),
20-
retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true),
21-
}));
18+
const { countActiveDescendantRunsMock, maybeApplyTtsToPayloadMock, retireSessionMcpRuntimeMock } =
19+
vi.hoisted(() => ({
20+
countActiveDescendantRunsMock: vi.fn().mockReturnValue(0),
21+
maybeApplyTtsToPayloadMock: vi.fn(async (params: { payload: unknown }) => params.payload),
22+
retireSessionMcpRuntimeMock: vi.fn().mockResolvedValue(true),
23+
}));
2224

2325
vi.mock("../../config/sessions/main-session.js", () => ({
2426
resolveAgentMainSessionKey: vi.fn(({ agentId }: { agentId: string }) => `agent:${agentId}:main`),
@@ -66,6 +68,10 @@ vi.mock("../../infra/system-events.js", () => ({
6668
enqueueSystemEvent: vi.fn(),
6769
}));
6870

71+
vi.mock("../../tts/tts.runtime.js", () => ({
72+
maybeApplyTtsToPayload: maybeApplyTtsToPayloadMock,
73+
}));
74+
6975
vi.mock("./subagent-followup-hints.js", () => ({
7076
expectsSubagentFollowup: vi.fn().mockReturnValue(false),
7177
isLikelyInterimCronMessage: vi.fn().mockReturnValue(false),
@@ -181,6 +187,7 @@ describe("dispatchCronDelivery — double-announce guard", () => {
181187
vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined);
182188
vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined);
183189
vi.mocked(retireSessionMcpRuntime).mockResolvedValue(true);
190+
maybeApplyTtsToPayloadMock.mockReset().mockImplementation(async (params) => params.payload);
184191
});
185192

186193
afterEach(() => {
@@ -336,6 +343,62 @@ describe("dispatchCronDelivery — double-announce guard", () => {
336343
).toBe(false);
337344
});
338345

346+
it("applies TTS directives before direct cron announce delivery", async () => {
347+
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
348+
vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);
349+
maybeApplyTtsToPayloadMock.mockImplementation(async (params: { payload: unknown }) => {
350+
const payload = params.payload as { text?: string };
351+
expect(payload.text).toBe("[[tts]] Morning briefing complete.");
352+
return {
353+
text: "Morning briefing complete.",
354+
mediaUrl: "file:///tmp/cron-tts.mp3",
355+
audioAsVoice: true,
356+
spokenText: "Morning briefing complete.",
357+
};
358+
});
359+
360+
const params = makeBaseParams({
361+
synthesizedText: "[[tts]] Morning briefing complete.",
362+
runStartedAt: 1_000,
363+
});
364+
params.cfgWithAgentDefaults = {
365+
messages: {
366+
tts: {
367+
auto: "tagged",
368+
provider: "microsoft",
369+
},
370+
},
371+
} as never;
372+
373+
const state = await dispatchCronDelivery(params);
374+
375+
expect(state.deliveryAttempted).toBe(true);
376+
expect(state.delivered).toBe(true);
377+
expect(maybeApplyTtsToPayloadMock).toHaveBeenCalledWith(
378+
expect.objectContaining({
379+
cfg: params.cfgWithAgentDefaults,
380+
channel: "telegram",
381+
kind: "final",
382+
agentId: "main",
383+
accountId: undefined,
384+
}),
385+
);
386+
expect(deliverOutboundPayloads).toHaveBeenCalledWith(
387+
expect.objectContaining({
388+
channel: "telegram",
389+
to: "123456",
390+
payloads: [
391+
{
392+
text: "Morning briefing complete.",
393+
mediaUrl: "file:///tmp/cron-tts.mp3",
394+
audioAsVoice: true,
395+
spokenText: "Morning briefing complete.",
396+
},
397+
],
398+
}),
399+
);
400+
});
401+
339402
it("preserves all successful text payloads for direct delivery", async () => {
340403
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
341404
vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);

src/cron/isolated-agent/delivery-dispatch.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
resolveMainSessionKey,
1414
} from "../../config/sessions/main-session.js";
1515
import type { OpenClawConfig } from "../../config/types.openclaw.js";
16+
import type { TtsAutoMode } from "../../config/types.tts.js";
1617
import { sleepWithAbort } from "../../infra/backoff.js";
1718
import { formatErrorMessage } from "../../infra/errors.js";
1819
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js";
@@ -24,6 +25,7 @@ import {
2425
normalizeOptionalLowercaseString,
2526
normalizeOptionalString,
2627
} from "../../shared/string-coerce.js";
28+
import { shouldAttemptTtsPayload } from "../../tts/tts-config.js";
2729
import { createCronExecutionId } from "../run-id.js";
2830
import { hasScheduledNextRunAtMs } from "../service/jobs.js";
2931
import type { CronJob, CronRunTelemetry } from "../types.js";
@@ -119,6 +121,7 @@ type DispatchCronDeliveryParams = {
119121
deliveryPayloadHasStructuredContent: boolean;
120122
deliveryPayloads: ReplyPayload[];
121123
synthesizedText?: string;
124+
ttsAuto?: TtsAutoMode;
122125
summary?: string;
123126
outputText?: string;
124127
telemetry?: CronRunTelemetry;
@@ -183,6 +186,7 @@ let deliveryLoggerRuntimePromise:
183186
let subagentFollowupRuntimePromise:
184187
| Promise<typeof import("./subagent-followup.runtime.js")>
185188
| undefined;
189+
let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | undefined;
186190

187191
const COMPLETED_DIRECT_CRON_DELIVERIES = new Map<string, CompletedDirectCronDelivery>();
188192

@@ -217,6 +221,11 @@ async function loadSubagentFollowupRuntime(): Promise<
217221
return await subagentFollowupRuntimePromise;
218222
}
219223

224+
async function loadTtsRuntime(): Promise<typeof import("../../tts/tts.runtime.js")> {
225+
ttsRuntimePromise ??= import("../../tts/tts.runtime.js");
226+
return await ttsRuntimePromise;
227+
}
228+
220229
async function logCronDeliveryWarn(message: string): Promise<void> {
221230
const { logWarn } = await loadDeliveryLoggerRuntime();
222231
logWarn(message);
@@ -303,6 +312,40 @@ function getCompletedDirectCronDelivery(
303312
return cloneDeliveryResults(cached.results);
304313
}
305314

315+
async function maybeApplyTtsToCronPayloads(params: {
316+
cfg: OpenClawConfig;
317+
payloads: ReplyPayload[];
318+
delivery: SuccessfulDeliveryTarget;
319+
agentId: string;
320+
ttsAuto?: TtsAutoMode;
321+
}): Promise<ReplyPayload[]> {
322+
if (
323+
!shouldAttemptTtsPayload({
324+
cfg: params.cfg,
325+
ttsAuto: params.ttsAuto,
326+
agentId: params.agentId,
327+
channelId: params.delivery.channel,
328+
accountId: params.delivery.accountId,
329+
})
330+
) {
331+
return params.payloads;
332+
}
333+
const { maybeApplyTtsToPayload } = await loadTtsRuntime();
334+
return await Promise.all(
335+
params.payloads.map((payload) =>
336+
maybeApplyTtsToPayload({
337+
payload,
338+
cfg: params.cfg,
339+
channel: params.delivery.channel,
340+
kind: "final",
341+
ttsAuto: params.ttsAuto,
342+
agentId: params.agentId,
343+
accountId: params.delivery.accountId,
344+
}),
345+
),
346+
);
347+
}
348+
306349
function buildDirectCronDeliveryIdempotencyKey(params: {
307350
jobId: string;
308351
runStartedAt: number;
@@ -524,7 +567,7 @@ export async function dispatchCronDelivery(
524567
: synthesizedText
525568
? [{ text: synthesizedText }]
526569
: [];
527-
const payloadsForDelivery = rawPayloads
570+
const normalizedPayloads = rawPayloads
528571
.map((p) => {
529572
if (!p.text) {
530573
return p;
@@ -535,7 +578,7 @@ export async function dispatchCronDelivery(
535578
});
536579
})
537580
.filter((p) => hasReplyPayloadContent(p, { trimText: true }));
538-
if (payloadsForDelivery.length === 0) {
581+
if (normalizedPayloads.length === 0) {
539582
return await finishSilentReplyDelivery();
540583
}
541584
if (params.isAborted()) {
@@ -575,6 +618,18 @@ export async function dispatchCronDelivery(
575618
...params.telemetry,
576619
});
577620
}
621+
const payloadsForDelivery = (
622+
await maybeApplyTtsToCronPayloads({
623+
cfg: params.cfgWithAgentDefaults,
624+
payloads: normalizedPayloads,
625+
delivery,
626+
agentId: params.agentId,
627+
ttsAuto: params.ttsAuto,
628+
})
629+
).filter((p) => hasReplyPayloadContent(p, { trimText: true }));
630+
if (payloadsForDelivery.length === 0) {
631+
return await finishSilentReplyDelivery();
632+
}
578633
deliveryAttempted = true;
579634
const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey);
580635
if (cachedResults) {

src/cron/isolated-agent/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ async function finalizeCronRun(params: {
958958
deliveryPayloadHasStructuredContent,
959959
deliveryPayloads,
960960
synthesizedText,
961+
ttsAuto: prepared.cronSession.sessionEntry.ttsAuto,
961962
summary,
962963
outputText,
963964
telemetry,

0 commit comments

Comments
 (0)