diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d004dabc1..7f92e479b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc. - Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc. - Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120. +- Channels/delivery: mark message-tool-only turns with generated-but-suppressed visible output as `source-reply-delivery-suppressed` in diagnostics, including rich presentation replies, so successful-but-private channel runs are easier to identify. Fixes #76980. - Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946. - Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc. - Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374. diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts index 8e5370090bc..38d85bfdfaa 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts @@ -82,6 +82,7 @@ function createMessageEndContext( finalizeAssistantTexts?: ReturnType; consumeReplyDirectives?: ReturnType; warn?: ReturnType; + debug?: ReturnType; builtinToolNames?: ReadonlySet; state?: Record; } = {}, @@ -125,7 +126,7 @@ function createMessageEndContext( noteLastAssistant: vi.fn(), recordAssistantUsage: vi.fn(), commitAssistantUsage: vi.fn(), - log: { debug: vi.fn(), warn: params.warn ?? vi.fn() }, + log: { debug: params.debug ?? vi.fn(), warn: params.warn ?? vi.fn() }, builtinToolNames: params.builtinToolNames, stripBlockTags: (text: string) => text, finalizeAssistantTexts: params.finalizeAssistantTexts ?? vi.fn(), @@ -756,11 +757,13 @@ describe("handleMessageEnd", () => { }); it("does not duplicate block reply for text_end channels even when stripping differs", () => { + const debug = vi.fn(); const onBlockReply = vi.fn(); const emitBlockReply = vi.fn(); // Same pattern: directive accumulator returns null for empty final flush const consumeReplyDirectives = vi.fn((text: string) => (text ? { text } : null)); const ctx = createMessageEndContext({ + debug, onBlockReply, emitBlockReply, consumeReplyDirectives, @@ -789,6 +792,9 @@ describe("handleMessageEnd", () => { // send should NOT fire for text_end channels. The only consumeReplyDirectives // call is the final empty flush which returns null. expect(emitBlockReply).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledWith( + "Skipping message_end safety send for text_end channel - content already emitted via text_end", + ); }); it("emits a replacement final assistant event when final_answer appears only at message_end", () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index a1c56e085ef..78578460d68 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -856,7 +856,7 @@ export function handleMessageEnd( // lastBlockReplyText is still null and message_end must deliver. if (ctx.state.blockReplyBreak === "text_end" && ctx.state.lastBlockReplyText != null) { ctx.log.debug( - `Skipping message_end safety send for text_end channel - content already delivered via text_end`, + `Skipping message_end safety send for text_end channel - content already emitted via text_end`, ); } else { // Check for duplicates before emitting (same logic as emitBlockChunk). diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index edb37f73ad6..9b9c6121382 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -4587,6 +4587,50 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); + it("marks diagnostics when message-tool-only suppresses generated visible output", async () => { + setNoAbort(); + const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; + const dispatcher = createDispatcher(); + const richPayload = { + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Open", url: "https://example.test/result" }], + }, + ], + }, + } satisfies ReplyPayload; + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await opts?.onBlockReply?.(richPayload); + return richPayload; + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "channel", + Provider: "discord", + Surface: "discord", + SessionKey: "test:discord:channel:C1", + }), + cfg, + dispatcher, + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(result.queuedFinal).toBe(false); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "completed", + reason: "source-reply-delivery-suppressed", + sessionKey: "test:discord:channel:C1", + }), + ); + }); + it("does not auto-route same-provider group/channel final replies in message-tool-only mode", async () => { setNoAbort(); mocks.routeReply.mockClear(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index fccb38f84b6..cfda161eb44 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -41,6 +41,7 @@ import { import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { logMessageProcessed, logMessageQueued, @@ -760,6 +761,21 @@ export async function dispatchReplyFromConfig( sourceReplyDeliveryMode === "message_tool_only" ? { ...result, sourceReplyDeliveryMode } : result; + let suppressedSourceReplyContent = false; + const markSuppressedSourceReplyContent = (payload: ReplyPayload) => { + if (!suppressDelivery || payload.isReasoning === true) { + return; + } + if (!hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice })) { + return; + } + if (!suppressedSourceReplyContent) { + logVerbose( + `automatic source reply content suppressed by ${deliverySuppressionReason || "delivery policy"}; visible output requires the message tool or automatic visible replies`, + ); + } + suppressedSourceReplyContent = true; + }; let pluginFallbackReason: | "plugin-bound-fallback-missing-plugin" @@ -1352,6 +1368,7 @@ export async function dispatchReplyFromConfig( markInboundDedupeReplayUnsafe(); } if (suppressDelivery) { + markSuppressedSourceReplyContent(payload); return; } // Suppress reasoning payloads — channels using this generic dispatch @@ -1540,6 +1557,10 @@ export async function dispatchReplyFromConfig( ); } } + } else { + for (const reply of replies) { + markSuppressedSourceReplyContent(reply); + } } const counts = dispatcher.getQueuedCounts(); @@ -1547,7 +1568,11 @@ export async function dispatchReplyFromConfig( commitInboundDedupeIfClaimed(); recordProcessed( "completed", - pluginFallbackReason ? { reason: pluginFallbackReason } : undefined, + pluginFallbackReason + ? { reason: pluginFallbackReason } + : suppressedSourceReplyContent + ? { reason: "source-reply-delivery-suppressed" } + : undefined, ); markIdle("message_completed"); return attachSourceReplyDeliveryMode({ queuedFinal, counts });