Skip to content

fix(auto-reply): gate sendBlockReply on disableBlockStreaming#2

Draft
reason-bot wants to merge 2 commits into
mainfrom
lanny/fix-block-streaming-dispatcher-gate
Draft

fix(auto-reply): gate sendBlockReply on disableBlockStreaming#2
reason-bot wants to merge 2 commits into
mainfrom
lanny/fix-block-streaming-dispatcher-gate

Conversation

@reason-bot

Copy link
Copy Markdown
Collaborator

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, currently P1, 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.postMessage calls to Slack. replyPlan.nextThreadTs() returns a threadTs only 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 resolving channels.slack.streaming.block.enabled: false) is threaded through replyOptions and correctly disables the streamed block-reply pipeline in agent-runner.runtime. It does not disable the plain block-delivery path:

  • dispatch-from-config.ts's onBlockReply handler fires on every assistant text block between tool calls; its exit gates cover suppressDelivery, reasoning/commentary channel opt-ins, and empty text, but not disableBlockStreaming.
  • The handler ends by calling dispatcher.sendBlockReply(normalizedPayload).
  • sendBlockReply in reply-dispatcher.ts is enqueue("block", payload) with no gate — it drives one deliver() per payload with a ~130ms inter-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.ts was never wired to that flag. Two consumers of disableBlockStreaming, one honored, one not.

The fix (defense in depth)

  1. src/auto-reply/reply/reply-dispatcher.ts — Add disableBlockStreaming?: boolean to ReplyDispatcherOptions. sendBlockReply returns false (matching the existing normalization-skip return contract) when set. sendToolResult and sendFinalReply are unchanged.

  2. src/auto-reply/reply/dispatch-from-config.ts — Early-return in the runtime's onBlockReply handler when params.replyOptions?.disableBlockStreaming === true and 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.

  3. src/auto-reply/dispatch.ts — Hoist disableBlockStreaming from incoming replyOptions into the dispatcher options in both dispatchInboundMessageWithBufferedDispatcher and dispatchInboundMessageWithDispatcher, so channels that only set it on replyOptions (Slack does) get the dispatcher-level gate too. Channel-provided dispatcherOptions.disableBlockStreaming takes precedence when both are set.

  4. 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 explicitly false. 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-reply project — 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.
  • Runtime at /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:

Related / follow-up (not in this PR)

  • extensions/feishu/src/reply-dispatcher.ts and extensions/msteams/src/reply-dispatcher.ts are independent dispatcher implementations. Same class of gap likely applies; would want parallel patches, but out of scope here.
  • openclaw plugins.allow is empty in the local config, which produces a repeated [plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load warning in gateway logs. Unrelated to this fix.

Notes for reviewer

  • Base is ReasonFoundation/openclaw:main (2 commits ahead of openclaw/openclaw:main with the Android fixes). Rebased cleanly.
  • Happy to retarget upstream if you'd rather I opened the PR against openclaw/openclaw:main directly.
  • Draft status because I want an internal review before this goes external, and because the runtime hasn't been restarted with the patched build yet (SHA-verified untouched — behavior verification is unit-test-only for now).

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant