fix: mark suppressed source replies in diagnostics

This commit is contained in:
Peter Steinberger
2026-05-04 02:11:57 +01:00
parent 0659c58df8
commit 53e513b32c
5 changed files with 79 additions and 3 deletions

View File

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

View File

@@ -82,6 +82,7 @@ function createMessageEndContext(
finalizeAssistantTexts?: ReturnType<typeof vi.fn>;
consumeReplyDirectives?: ReturnType<typeof vi.fn>;
warn?: ReturnType<typeof vi.fn>;
debug?: ReturnType<typeof vi.fn>;
builtinToolNames?: ReadonlySet<string>;
state?: Record<string, unknown>;
} = {},
@@ -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", () => {

View File

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

View File

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

View File

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