fix(auto-reply): gate sendBlockReply on disableBlockStreaming#2
Draft
reason-bot wants to merge 2 commits into
Draft
fix(auto-reply): gate sendBlockReply on disableBlockStreaming#2reason-bot wants to merge 2 commits into
reason-bot wants to merge 2 commits into
Conversation
The channel-side setting `channels.<provider>.streaming.block.enabled=false` (e.g. Slack's) resolves to `disableBlockStreaming: true` on the get-reply options and correctly disables the streamed block-reply pipeline, but the plain block-delivery path was unaffected: inter-tool assistant-text blocks flowed through the reply dispatcher and became one outbound `chat.postMessage` per block (Slack: one turn producing a burst of same-second channel-root posts under a single traceId/spanId with no parentSpanId). Gate the drop in two places for defense in depth: 1. Reply dispatcher primitive: add `disableBlockStreaming` to `ReplyDispatcherOptions`; `sendBlockReply` returns `false` (matching the existing normalization-skip semantics) when it is set. Tool results and final replies stay untouched. 2. Runtime block-reply handler in `dispatch-from-config.ts`: early-return before the accumulate/TTS/normalize work when `replyOptions.disableBlockStreaming === true`, while preserving the opt-in behaviour of durable reasoning/commentary lanes. Thread the flag from the incoming `replyOptions` through both `dispatchInboundMessageWithBufferedDispatcher` and `dispatchInboundMessageWithDispatcher` so channels that only set it on `replyOptions` (like Slack) get gated at the dispatcher too. Adds `reply-dispatcher.disable-block-streaming.test.ts` covering the disabled drop, the default pass-through, and explicit `false` pass-through. `vitest.auto-reply-reply` project: 2,597 tests pass. Related: openclaw#23791, openclaw#25592.
…ayload gate Two tests in dispatch-from-config.test.ts mirror the existing isReasoning/isCommentary suppression patterns to cover the new gate: 1. When replyOptions.disableBlockStreaming is true, plain inter-tool text blocks emitted via opts.onBlockReply do not reach dispatcher.sendBlockReply; the final reply still lands via sendFinalReply. This is the Slack-in-production repro. 2. When disableBlockStreaming is unset, the default block-delivery path is unchanged and both blocks reach the dispatcher (defense against accidentally turning the gate on by default). Runs inside the existing dispatch-from-config test harness so the gate is exercised against the real runtime handler, not just the dispatcher primitive.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the inter-tool block-payload leak on channels that set
channels.<provider>.streaming.block.enabled = false(Slack in our case; class of bug tracked upstream as openclaw/openclaw#25592 — canonical, currentlyP1,impact:message-loss,clawsweeper:linked-pr-open).Behavior before this patch: one agent turn producing N text blocks between tool calls resulted in N separate
chat.postMessagecalls to Slack.replyPlan.nextThreadTs()returns athreadTsonly for the first payload, so payloads 2..N post as top-level channel messages — hence the "one clean reply in-thread, then a rapid burst of unrelated-looking channel-root posts" pattern.After this patch: those inter-tool text blocks are dropped at the dispatcher. Tool results and the final reply still flow. Durable reasoning/commentary lanes (which have their own opt-in via
reasoningPayloadsEnabled/commentaryPayloadsEnabled) are untouched.Root cause
disableBlockStreaming: true(produced by resolvingchannels.slack.streaming.block.enabled: false) is threaded throughreplyOptionsand correctly disables the streamed block-reply pipeline inagent-runner.runtime. It does not disable the plain block-delivery path:dispatch-from-config.ts'sonBlockReplyhandler fires on every assistant text block between tool calls; its exit gates coversuppressDelivery, reasoning/commentary channel opt-ins, and empty text, but notdisableBlockStreaming.dispatcher.sendBlockReply(normalizedPayload).sendBlockReplyinreply-dispatcher.tsisenqueue("block", payload)with no gate — it drives onedeliver()per payload with a~130msinter-block human-delay sleep (matching the observed cadence in logs).Runtime designers assumed "if block streaming is off, upstream won't emit block payloads at all," but the emission path in
dispatch-from-config.tswas never wired to that flag. Two consumers ofdisableBlockStreaming, one honored, one not.The fix (defense in depth)
src/auto-reply/reply/reply-dispatcher.ts— AdddisableBlockStreaming?: booleantoReplyDispatcherOptions.sendBlockReplyreturnsfalse(matching the existing normalization-skip return contract) when set.sendToolResultandsendFinalReplyare unchanged.src/auto-reply/reply/dispatch-from-config.ts— Early-return in the runtime'sonBlockReplyhandler whenparams.replyOptions?.disableBlockStreaming === trueand the payload isn't a durable reasoning/commentary lane. Placed after the reasoning/commentary channel-owned-lane checks so those keep their existing opt-in semantics. Avoids the normalize/TTS/accumulate work for a payload we're about to drop.src/auto-reply/dispatch.ts— HoistdisableBlockStreamingfrom incomingreplyOptionsinto the dispatcher options in bothdispatchInboundMessageWithBufferedDispatcheranddispatchInboundMessageWithDispatcher, so channels that only set it onreplyOptions(Slack does) get the dispatcher-level gate too. Channel-provideddispatcherOptions.disableBlockStreamingtakes precedence when both are set.src/auto-reply/reply/reply-dispatcher.disable-block-streaming.test.ts(new) — Three focused unit tests: drops block replies when enabled, passes through when unset, passes through when explicitlyfalse. Asserts tool + final still flow, and that drops don't count as cancellations (they were never enqueued).Belt-and-suspenders on purpose. Either gate is sufficient in isolation; both together cover future direct-emit paths that might bypass one layer or the other.
Verification
Run in the local checkout at
~/code/openclaw:vitest.auto-reply-replyproject — 2,597 tests pass (136 files), including 3 new + existing dispatcher / dispatch-from-config suites.vitest.auto-reply-core— 418 tests pass.vitest.auto-reply-top-level— 11 tests pass.tsgo -p tsconfig.core.json --noEmit— clean./opt/homebrew/lib/node_modules/openclaw/dist/was not touched (SHA-verified pre- and post-diagnosis).Prior art / not covered by
Searched issues + PRs (open and closed) before writing this:
agents.defaults.enforceFinalTag(prompt-level, only helps tag-reasoning providers). Some form landed in current source.CONFLICTING. Modifies the same file with a different mechanism (buffer blocks until final; drop ifNO_REPLY). Complementary, not overlapping: fix(auto-reply): turn-level NO_REPLY substrate-leak suppression (buffer-until-final) openclaw/openclaw#86175 handles "turn ended in NO_REPLY" case; this PR handles "channel opted out entirely" case. Neither obsoletes the other.Related / follow-up (not in this PR)
extensions/feishu/src/reply-dispatcher.tsandextensions/msteams/src/reply-dispatcher.tsare independent dispatcher implementations. Same class of gap likely applies; would want parallel patches, but out of scope here.openclaw plugins.allowis empty in the local config, which produces a repeated[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-loadwarning in gateway logs. Unrelated to this fix.Notes for reviewer
ReasonFoundation/openclaw:main(2 commits ahead ofopenclaw/openclaw:mainwith the Android fixes). Rebased cleanly.openclaw/openclaw:maindirectly.