diff --git a/CHANGELOG.md b/CHANGELOG.md index 3392e5031c74..ce8071f96c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly. - Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc. - Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty. +- Replies: deliver rich-only block replies even when block-streaming coalescing is enabled, keeping card and button payloads from being dropped by the text coalescer. Thanks @pashpashpash. - macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev. - Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc. - WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash. diff --git a/src/auto-reply/reply/block-reply-pipeline.test.ts b/src/auto-reply/reply/block-reply-pipeline.test.ts index 1b8ca2192482..4afa6fbe0da7 100644 --- a/src/auto-reply/reply/block-reply-pipeline.test.ts +++ b/src/auto-reply/reply/block-reply-pipeline.test.ts @@ -167,6 +167,31 @@ describe("createBlockReplyPipeline dedup with threading", () => { expect(sent).toHaveLength(2); }); + it("bypasses text coalescing for rich-only payloads", async () => { + const sent: Array<{ presentation?: unknown }> = []; + const pipeline = createBlockReplyPipeline({ + onBlockReply: async (payload) => { + sent.push({ presentation: payload.presentation }); + }, + timeoutMs: 5000, + coalescing: { + minChars: 1, + maxChars: 200, + idleMs: 0, + joiner: "\n\n", + }, + }); + + const presentation = { + blocks: [{ type: "buttons" as const, buttons: [{ label: "Open", value: "open" }] }], + }; + + pipeline.enqueue({ presentation }); + await pipeline.flush({ force: true }); + + expect(sent).toEqual([{ presentation }]); + }); + it("does not track media when text-only blocks are delivered", async () => { const pipeline = createBlockReplyPipeline({ onBlockReply: async () => {}, diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index 4d6f804bba5a..f0a15a4bd689 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -1,4 +1,7 @@ -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import type { ReplyPayload } from "../types.js"; @@ -233,8 +236,12 @@ export function createBlockReplyPipeline(params: { if (bufferPayload(payload)) { return; } - const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; - if (hasMedia) { + const reply = resolveSendableOutboundReplyParts(payload); + const hasNonTextContent = hasOutboundReplyContent( + { ...payload, text: undefined }, + { trimText: true }, + ); + if (reply.hasMedia || hasNonTextContent) { void coalescer?.flush({ force: true }); sendPayload(payload, /* bypassSeenCheck */ false); return;