fix(auto-reply): preserve silent voice payloads

This commit is contained in:
Peter Steinberger
2026-04-28 09:27:25 +01:00
parent a3bbcf2792
commit 28bf71d74b
3 changed files with 34 additions and 2 deletions

View File

@@ -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 <message>` and `/new <message>` 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.

View File

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

View File

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