Skip to content

fix(slack): debounce app_mention edit re-fires to one turn per message#1104

Closed
dogzzdogzz wants to merge 2 commits into
openabdev:mainfrom
dogzzdogzz:fix/slack-mention-debounce
Closed

fix(slack): debounce app_mention edit re-fires to one turn per message#1104
dogzzdogzz wants to merge 2 commits into
openabdev:mainfrom
dogzzdogzz:fix/slack-mention-debounce

Conversation

@dogzzdogzz

@dogzzdogzz dogzzdogzz commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Debounce Slack app_mention so one user message produces one agent turn.

What problem does this solve?

src/slack.rs spawns a full handle_message (→ one agent turn) for every app_mention event, with no dedup by message ts. But a single logical mention arrives as several events: Slack re-delivers app_mention as a mention-bearing message moves through edit states, and openab's own native streaming drives a reply through several message states (chat.startStream → N×appendStreamchat.stopStream → final chat.update). One observed multi-agent thread produced 5 agent turns for 2 real messages — duplicate LLM invocations, each forcing a no-op reply into the thread, and one dispatch even fired on a mid-stream fragment whose text was just <@U…> -.

Closes #1103

At a Glance

(before)  every app_mention event ─▶ tokio::spawn(handle_message) ─▶ full agent turn

          edit-state re-fires of the SAME (channel, ts):
            ts=170.1  "<@U  -"           ─▶ turn 1   ⚠ mid-stream fragment
            ts=170.1  "<@U review"       ─▶ turn 2
            ts=170.1  "<@U review this"  ─▶ turn 3        1 message ⇒ N turns ⇒ N noise replies

(after)   app_mention ─▶ MentionDebouncer.note((channel, ts), latest_state)
                          each new state pushes a quiet deadline (mention_debounce_seconds, default 4s)
              quiet ─▶ dispatch ONCE, only if fingerprint changed
                          (text + sorted file IDs)  ─▶ one turn on the settled message

Prior Art & Industry Research

  • OpenClaw / Hermes Agent: not applicable — this is a defect in OpenAB's own Slack Socket Mode event ingestion (src/slack.rs), upstream of any agent backend and independent of which ACP agent runs.
  • OpenAB turn-boundary batching (docs/adr/turn-boundary-batching.md, the Dispatcher): the closest in-repo prior art. It coalesces distinct messages that arrive during an in-flight turn into one batch. This change is complementary and orthogonal — it dedupes repeated edit states of the same message at the adapter ingress, before a turn is ever dispatched, which the batching dispatcher does not cover.
  • OpenAB WeCom adapter (debounceSecs): precedent for a per-adapter, seconds-valued "debounce quiet period before flushing" config — the naming/units of mention_debounce_seconds follow it.
  • Slack Events API: app_mention delivery is at-least-once and re-fires as a message is edited (message_changed). Trailing-edge debounce keyed on (channel, ts) is the standard way to collapse such an event storm down to the message's settled state.

Proposed Solution

Add MentionDebouncer, keyed on (channel, ts):

  • Hold only the latest event state per message; each new state pushes a quiet deadline.
  • On quiet, dispatch once, and only if the settled state's fingerprint (text + sorted attached file IDs) differs from what was last dispatched for that message — so an attachment-only edit is still material, while an identical re-fire drops.
  • The deadline check and the drain happen under one lock (try_take), closing the race where a new edit arriving mid-check would otherwise leak an unsettled state through.
  • Fail-open: a late final state is never lost (a material edit re-dispatches). Dispatch history is FIFO-bounded (MENTION_HISTORY_CAP).

The quiet window is configurable via [slack] mention_debounce_seconds (Helm: slack.mentionDebounceSeconds), default 4. Set it lower to trade robustness for latency, or 0 to dispatch on the first event (effectively disabling debounce). The window defaults to MENTION_DEBOUNCE (4s) when unset.

Trailing-edge is deliberate: the first event state of a streamed/edited message is often a mid-stream fragment, and responding to fragments is worse than a few seconds of latency on turns that already take tens of seconds.

Why this approach?

The bug is at the adapter ingress (one Slack message → many events), so the fix belongs there, before dispatch — not in the cross-platform batching Dispatcher, whose job (coalescing distinct messages at turn boundaries) is a different concern. Keying on (channel, ts) matches Slack's own identity for "the same message across its edit states". The change is contained to the Slack adapter plus a single, default-preserving config knob.

Alternatives Considered

  • Leading-edge (dispatch the first event, ignore the rest): rejected — the first state is often a mid-stream fragment (observed: text just <@U…> -); responding to fragments is worse than trailing latency.
  • Dedup in the batching Dispatcher: the Dispatcher batches distinct messages; same-ts edit states are not its model, and pushing per-message edit dedup down there would entangle a Slack-specific event quirk with the platform-agnostic batching logic.
  • Dedup by Slack event_id / client_msg_id: wouldn't collapse genuine edit states (each edit is a fresh delivery of the same logical message); (channel, ts) + content fingerprint is the right grain.
  • mention_debounce_ms (millisecond units): rejected for _seconds — this is a coarse, human-tuned window (3–5s), matching the repo's seconds-valued duration config (timeout_seconds, *_secs, WeCom debounceSecs) rather than the sub-second ReactionTiming _ms knobs.

Validation

Built and tested locally against this branch:

  • cargo test --bin openab: 501 passed, 1 failed. The one failure (secrets::tests::resolve_exec_nonzero_exit) reproduces on a clean tree in this environment (asserts a shell error-message string) and is unrelated to this change — src/secrets.rs is untouched here.
  • Debounce unit tests green: mention_debounce_latest_state_wins_and_refires_drop, mention_debounce_try_take_respects_extended_deadline, mention_debounce_history_is_bounded (latest-state-wins, identical-re-fire-drop, attachment-only-edit-is-material, the mid-check deadline race, and FIFO-bounded history).
  • cargo clippy --all-targets -- -D warnings: clean on all changed files. (Two pre-existing clippy::bool_assert_comparison findings in the untouched src/cron.rs are surfaced only by a newer local clippy; not introduced here.)
  • helm template: slack.mentionDebounceSeconds=N renders mention_debounce_seconds = N under [slack]; omitted when unset (Rust default applies).

Please re-verify via CI.

Slack re-fires `app_mention` for every edit state of a mention-bearing
message, and native streaming (chat.startStream -> N x appendStream ->
chat.stopStream -> final chat.update) makes a single reply produce
several such states. Each state previously dispatched a full agent turn,
producing phantom turns (duplicate agent invocations) and forced no-op
replies in the thread.

Add MentionDebouncer keyed on (channel, message ts): hold only the latest
event state, push a 4s quiet deadline on each new state, then dispatch
once on quiet -- only if the state's fingerprint (text + sorted attached
file IDs) differs from what was last dispatched for that message. The
deadline check and drain happen under one lock (no TOCTOU leak of an
unsettled state); identical re-fires drop, material edits re-dispatch
(fail-open, so a late final state is never lost). Dispatch history is
FIFO-bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dogzzdogzz dogzzdogzz requested a review from thepagent as a code owner June 14, 2026 02:35
@openab-app

openab-app Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

⚠️ This PR is missing a Discord Discussion URL in the body.

All PRs must reference a prior Discord discussion to ensure community alignment before implementation.

Please edit the PR description to include a link like:

Discord Discussion URL: https://discord.com/channels/...

This PR will be automatically closed in 24 hours if the link is not added.

@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 14, 2026
Expose the previously-hardcoded 4s mention debounce window as
`[slack] mention_debounce_seconds` (Helm: `slack.mentionDebounceSeconds`),
default 4. Deployments can tune the quiet window, or set 0 to dispatch on
the first event (disabling debounce).

`MentionDebouncer` now holds the window as a field (Default stays 4s via
`MENTION_DEBOUNCE`); `run_slack_adapter` threads the configured value
through from `SlackConfig`. Helm chart renders `mention_debounce_seconds`
only when the value is set, so the Rust default applies otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dogzzdogzz

Copy link
Copy Markdown
Contributor Author

Closing in favor of a simpler, source-level fix. Rather than debouncing the app_mention event stream, we'll gate streaming itself: a [slack] streaming toggle (mirroring the existing [gateway] streaming field) that posts a single final message instead of streaming via chat.startStream/appendStream/update.

The phantom turns originate in multi-agent threads, where a streamed reply that @-mentions another bot re-delivers that mention across the message's in-progress states; send-once collapses that to one delivery with none of the debounce timing/fingerprint/race machinery. Trade-off: no live typewriter streaming. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. pending-community-review slack

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(slack): app_mention re-fires for every edit state → duplicate agent turns (phantom turns)

1 participant