Summary
The Slack adapter spawns a full agent turn for every app_mention event, with no dedup by message ts. A single logical mention, however, commonly arrives as several app_mention 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×appendStream → chat.stopStream → final chat.update). The result is multiple duplicate agent invocations ("phantom turns") for one user message, each of which also forces a reply into the thread.
Environment
- openab
main (src/slack.rs app_mention handler)
- Slack adapter, Socket Mode
- Multi-agent thread configuration (≥2 bots),
assistant_mode default
Observed behavior
In a multi-agent workflow thread, a single exchange of 2 real user messages produced 5 agent turns. The extra turns:
- share the same sender and
(channel, ts);
- carry token counts that track the message's growth as it settles (e.g.
75 → 90 tokens), i.e. they are successive edit states of the same message;
- queue behind one another (dispatch wait climbing ~
12s → 24s → 32s as duplicates pile up);
- each emit a forced reply (often a no-op), which is pure thread noise.
One dispatch was even observed acting on a mid-stream fragment whose text was just <@U…> - — the mention captured before it had settled.
Root cause (src/slack.rs)
The app_mention branch of the Socket Mode loop spawns handle_message unconditionally for each event:
"app_mention" => {
// ... bot gating ...
let event = event.clone();
// ...
tokio::spawn(async move {
handle_message(&event, /* ... */).await;
});
}
There is no per-message dedup, so every edit-state re-fire of the same (channel, ts) becomes its own turn. The turn-boundary batching dispatcher (docs/adr/turn-boundary-batching.md) coalesces distinct messages that arrive during an in-flight turn, but it does not dedupe repeated edit states of the same message.
Reproduction
- A Slack workspace with a bot in a multi-agent thread (≥2 bots),
assistant_mode default (native streaming).
@mention the bot with a message and let the reply stream (and/or edit the mention message once after sending).
- Observe multiple
handle_message dispatches / agent turns for the single mention, several of which emit a no-op reply.
Impact
- Duplicate agent invocations — wasted LLM cost and latency — for one user message.
- Thread noise: each phantom turn forces a reply.
- Especially visible in multi-agent threads, where native streaming is disabled (post+edit) and a reply produces several edit states.
Suggested fix
Debounce app_mention per (channel, ts): hold only the latest event state, dispatch once after a short quiet window (each new state pushes the deadline), and only if the settled state's fingerprint (text + attached file IDs) differs from what was last dispatched for that message. Trailing-edge, so the turn acts on the settled message rather than a mid-stream fragment; identical re-fires drop, material edits re-dispatch (fail-open). A PR implementing this follows.
Summary
The Slack adapter spawns a full agent turn for every
app_mentionevent, with no dedup by messagets. A single logical mention, however, commonly arrives as severalapp_mentionevents — Slack re-deliversapp_mentionas a mention-bearing message moves through edit states, and openab's own native streaming drives a reply through several message states (chat.startStream→ N×appendStream→chat.stopStream→ finalchat.update). The result is multiple duplicate agent invocations ("phantom turns") for one user message, each of which also forces a reply into the thread.Environment
main(src/slack.rsapp_mentionhandler)assistant_modedefaultObserved behavior
In a multi-agent workflow thread, a single exchange of 2 real user messages produced 5 agent turns. The extra turns:
(channel, ts);75 → 90tokens), i.e. they are successive edit states of the same message;12s → 24s → 32sas duplicates pile up);One dispatch was even observed acting on a mid-stream fragment whose text was just
<@U…> -— the mention captured before it had settled.Root cause (
src/slack.rs)The
app_mentionbranch of the Socket Mode loop spawnshandle_messageunconditionally for each event:There is no per-message dedup, so every edit-state re-fire of the same
(channel, ts)becomes its own turn. The turn-boundary batching dispatcher (docs/adr/turn-boundary-batching.md) coalesces distinct messages that arrive during an in-flight turn, but it does not dedupe repeated edit states of the same message.Reproduction
assistant_modedefault (native streaming).@mentionthe bot with a message and let the reply stream (and/or edit the mention message once after sending).handle_messagedispatches / agent turns for the single mention, several of which emit a no-op reply.Impact
Suggested fix
Debounce
app_mentionper(channel, ts): hold only the latest event state, dispatch once after a short quiet window (each new state pushes the deadline), and only if the settled state's fingerprint (text + attached file IDs) differs from what was last dispatched for that message. Trailing-edge, so the turn acts on the settled message rather than a mid-stream fragment; identical re-fires drop, material edits re-dispatch (fail-open). A PR implementing this follows.