Skip to content

Let an engaged Slack channel keep listening, and read the thread it joined - #1168

Merged
edwin-zvs merged 1 commit into
mainfrom
slack-follow-up-and-thread-context
Aug 2, 2026
Merged

Let an engaged Slack channel keep listening, and read the thread it joined#1168
edwin-zvs merged 1 commit into
mainfrom
slack-follow-up-and-thread-context

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Two per-channel options, both bounded:

[channels.my-bot]
follow_up = "thread"    # off | thread | channel
thread_context = 50     # earlier thread messages read on joining; 0 = none

Answering without being mentioned

A bot you must @-mention for every message can't hold a conversation — you keep re-addressing a participant that's visibly already in the room. DMs have always worked untagged; follow_up extends that to channels.

value behavior
thread (default) after being mentioned in a thread, answers later messages in that thread
channel after being mentioned anywhere in a channel, answers everything posted there
off only direct mentions and DMs

"Already engaged" is derived, not stored. A channel is engaged in a conversation exactly when it already routes a session for it. No second participation record that could drift from the routing table, survive a restart wrongly, or need expiry. Each thread stays its own session in every mode, so unrelated topics never share context.

Reading the thread it joined

A bot mentioned halfway down a thread couldn't answer "what do you think?" — all it ever received was those five words. thread_context reads the thread's earlier messages when the bot is first pulled in, and only then; afterwards the session has been present for everything said.

What you have to change in Slack

Neither works until the app grants it, and both fail closed:

feature needs without it
follow_up message.channels event subscription (+ message.groups for private) Slack sends no untagged messages → behaves as off
thread_context channels:history scope (+ groups:history) logs the refusal, answers from the message alone

So merging this changes nothing about your current bot until you reinstall the app with those.

Trust boundary — worth reading before enabling thread_context

This widens who can put text in front of a session that holds tools. Before, only the person addressing the bot supplied input; now everyone in the thread does.

Mitigations: history is fenced in a block explicitly marked as material to read rather than instructions to follow, it's confined to the one thread, and thread_context = 0 disables it. The fence is a mitigation, not a guarantee — that's stated in the docs too. If a channel's participants aren't people you'd let instruct the agent directly, leave it at 0.

A bug this would have introduced

Slack fires both app_mention and message.channels for a message that mentions the bot, under different event ids (docs). The existing dedup keyed on the event id, so the moment you enabled the second subscription, one message would have started two turns. Dedup is now keyed on the message (channel + ts), which also still absorbs Slack's own retries.

Also fixes a flaky test I introduced in #1166

Its stub server served one request per connection without Connection: close, so reqwest pooled the socket and the next call raced the server's close. The older tests in that file get this right; mine didn't. It failed ~1 run in 4 under load; 4 clean full-suite runs since.

Tests

7 new, covering: untagged channel messages parse as candidates rather than deliveries; the follow-up decision table; one message having one identity across both subscriptions; history fencing (including that an injection-looking line stays inside the fence and the triggering message isn't duplicated); empty-thread handling; engagement lookups being per-channel; and TOML parsing incl. defaults for definitions written before these options existed.

cargo test --workspace: 51 test binaries, 0 failures.

Note on flakes: service_supervisor port-binding tests flake under parallel load on this machine — I confirmed they flake on clean main at a similar rate (1 in 4), so they're pre-existing and unrelated.

Not covered by tests: the conversations.replies call itself is unit-tested against its parsed shape, not a live workspace.

Binary

Touches crates/daemon → the relevant binary is construct.

New spec 0179-an-engaged-channel-keeps-listening.md; docs/services.md gains both options.

…oined

A bot that must be @-mentioned for every message cannot hold a conversation:
the person asking has to keep re-addressing a participant that is visibly
already in the room. And a bot mentioned halfway down a thread could not
answer "what do you think?", because all it ever received was those five
words.

Two per-channel options, both bounded:

    [channels.my-bot]
    follow_up = "thread"    # off | thread | channel
    thread_context = 50     # earlier thread messages read on joining; 0 = none

follow_up decides where an untagged message still counts as ours. "Already
engaged" is derived, not stored: a channel is engaged in a conversation
exactly when it already routes a session for it. There is no second
participation record to drift from the routing table or need expiry. Each
thread stays its own session in every mode, so unrelated topics never share
context.

thread_context reads a thread's earlier messages when the bot is first pulled
into one, and only then — afterwards the session has been present for
everything said in the thread.

Both need Slack app changes the operator makes: the message.channels event
subscription, and the channels:history scope. Without them Slack sends no
untagged messages and refuses the read, so a channel behaves exactly as it
does today. A refused history read costs context, never the answer.

Reading a thread widens who can put text in front of a session that holds
tools: before this only the person addressing the bot supplied input, now
everyone in the thread does. History is fenced in a block marked as material
to read rather than instructions to follow, kept to the one thread, and
disabled with thread_context = 0. The fence is a mitigation, not a guarantee,
and it is documented as such.

Also keys request dedup on the message rather than the delivery. Slack fires
both app_mention and message.channels for a message that mentions the bot,
under different event ids, so the old event-keyed dedup would have let one
message start two turns as soon as the second subscription was enabled.

Fixes a flaky test introduced with the progress affordance: its stub served
one request per connection without Connection: close, so reqwest pooled the
socket and the next call raced the server's close.
@edwin-zvs
edwin-zvs merged commit 86f41ba into main Aug 2, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the slack-follow-up-and-thread-context branch August 2, 2026 15:48
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