From 28bf71d74b067f4a6148f4435d4d007a7843f7b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 09:27:25 +0100 Subject: [PATCH] fix(auto-reply): preserve silent voice payloads --- CHANGELOG.md | 1 + .../reply/agent-runner-payloads.test.ts | 27 ++++++++++++++++++- src/auto-reply/reply/agent-runner-payloads.ts | 8 +++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cffd2d247b..2eea22a5a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Auto-reply/commands: stop bare `/reset` and `/new` after reset hooks acknowledge the command, so non-ACP channels no longer fall through into empty provider calls while `/reset ` and `/new ` still seed the next model turn. Fixes #73367. Thanks @hoyanhan and @wenxu007. +- Auto-reply: preserve voice-note media from silent turns while continuing to suppress text and non-voice media, so `NO_REPLY` TTS replies still deliver the requested audio bubble. (#73406) Thanks @zqchris. - Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski. - Skills: require explicit `skills.entries.coding-agent.enabled` before exposing the bundled coding-agent skill, so installs with Codex on PATH but no OpenAI auth do not silently offer Codex delegation. Fixes #73358. Thanks @LaFleurAdvertising and @Sanjays2402. - Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui. diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index abc862f7d8b..91caf41ac71 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -371,7 +371,7 @@ describe("buildReplyPayloads media filter integration", () => { }); }); - it("drops all final payloads during silent turns, including media-only payloads", async () => { + it("drops non-voice final payloads during silent turns, including media-only payloads", async () => { const { replyPayloads } = await buildReplyPayloads({ ...baseParams, silentExpected: true, @@ -381,6 +381,31 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); + it("keeps voice media payloads during silent turns", async () => { + const { replyPayloads } = await buildReplyPayloads({ + ...baseParams, + silentExpected: true, + payloads: [{ text: "NO_REPLY", mediaUrl: "file:///tmp/voice.opus", audioAsVoice: true }], + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]).toMatchObject({ + text: undefined, + mediaUrl: "file:///tmp/voice.opus", + audioAsVoice: true, + }); + }); + + it("drops empty voice markers during silent turns", async () => { + const { replyPayloads } = await buildReplyPayloads({ + ...baseParams, + silentExpected: true, + payloads: [{ audioAsVoice: true }], + }); + + expect(replyPayloads).toHaveLength(0); + }); + it("suppresses warning text when silent media payloads fail normalization", async () => { const normalizeMediaPaths = async () => { throw new Error("file not found"); diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 351a899475d..8f288a72bed 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -87,6 +87,10 @@ async function normalizeSentMediaUrlsForDedupe(params: { return normalizedUrls; } +function shouldKeepPayloadDuringSilentTurn(payload: ReplyPayload): boolean { + return payload.audioAsVoice === true && resolveSendableOutboundReplyParts(payload).hasMedia; +} + export async function buildReplyPayloads(params: { payloads: ReplyPayload[]; isHeartbeat: boolean; @@ -165,7 +169,9 @@ export async function buildReplyPayloads(params: { }), ) ).filter(isRenderablePayload); - const silentFilteredPayloads = params.silentExpected ? [] : replyTaggedPayloads; + const silentFilteredPayloads = params.silentExpected + ? replyTaggedPayloads.filter(shouldKeepPayloadDuringSilentTurn) + : replyTaggedPayloads; // Drop final payloads only when block streaming succeeded end-to-end. // If streaming aborted (e.g., timeout), fall back to final payloads.