Skip to content

fix(slack): suppress explicit NO_REPLY sentinel before chat_postMessage#30936

Open
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/slack-no-reply-sentinel-30644
Open

fix(slack): suppress explicit NO_REPLY sentinel before chat_postMessage#30936
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/slack-no-reply-sentinel-30644

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

When the agent returns exactly NO_REPLY on a Slack turn (the explicit
"leave the thread untouched" sentinel some agents emit on group/thread
conversations), the Slack adapter posts the literal sentinel into the
channel/thread instead of treating the turn as intentional silence.

This adds a small final-send guard at the top of SlackAdapter.send that
matches the existing precedent in gateway/platforms/feishu_comment.py
(_NO_REPLY_SENTINEL + skip-delivery branch). When content.strip() == "NO_REPLY", send returns SendResult(success=True, raw_response={"suppressed": "NO_REPLY"}) without calling
chat_postMessage.

The check is exact and case-sensitive after whitespace trimming, so:

  • "NO_REPLY" and " NO_REPLY\n" are suppressed.
  • "I would not use NO_REPLY here" (longer prose mentioning the token) is
    still posted normally.
  • "" and "no_reply" are not the sentinel and fall through to the
    normal send path.

The guard is placed before the slash-command branch, so ephemeral slash
replies via response_url are also suppressed when the agent returns the
sentinel.

Audited siblings: gateway/platforms/feishu_comment.py already
implements this exact pattern (_NO_REPLY_SENTINEL + skip-delivery on
match). Other adapter send paths (Telegram, Discord, Matrix, WeChat,
Feishu chat, WhatsApp, Mattermost) do not currently treat NO_REPLY as
special — the issue scopes the fix to Slack, and the other adapters'
threading semantics differ enough that a one-shot rollout would be a
separate change. Happy to widen to the other gateway adapters if
preferred.

Related Issue

Fixes #30644

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/slack.py — module-level _NO_REPLY_SENTINEL and a
    guard at the top of SlackAdapter.send that short-circuits delivery when
    content.strip() == _NO_REPLY_SENTINEL.
  • tests/gateway/test_slack_no_reply_sentinel.py — new test file covering
    exact-match suppression, whitespace tolerance, longer-prose preservation,
    empty-string fall-through, and case-sensitive boundary.

How to Test

```
uv run --with pytest --with pytest-xdist --with pytest-asyncio --with pytest-timeout python3 -m pytest tests/gateway/test_slack_no_reply_sentinel.py tests/gateway/test_slack.py -v
```

Expected: 6 new tests pass + 186 existing Slack tests continue to pass.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run focused tests for the touched code and all pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15.x

Documentation & Housekeeping

  • I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
  • I've updated `cli-config.yaml.example` if I added/changed config keys — N/A
  • I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — N/A (pure adapter logic, no OS-specific paths)
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

N/A — covered by the new regression tests.

Copilot AI review requested due to automatic review settings May 23, 2026 12:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds explicit handling for a NO_REPLY sentinel in the Slack adapter so intentional silence does not post the literal sentinel to Slack, and introduces tests validating suppression/posting behavior.

Changes:

  • Introduce _NO_REPLY_SENTINEL = "NO_REPLY" in the Slack platform adapter.
  • Suppress chat_postMessage when content.strip() == "NO_REPLY" and return a successful SendResult with a suppressed marker.
  • Add pytest coverage for exact-match, whitespace, case-variant, and “mentioned in prose” scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
gateway/platforms/slack.py Adds a NO_REPLY sentinel constant and early-return suppression logic in SlackAdapter.send().
tests/gateway/test_slack_no_reply_sentinel.py Adds unit tests and module mocks to validate Slack suppression behavior without Slack deps.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 775 to 790
if not self._app:
return SendResult(success=False, error="Not connected")

if isinstance(content, str) and content.strip() == _NO_REPLY_SENTINEL:
logger.debug(
"[Slack] Suppressed explicit NO_REPLY sentinel for channel %s",
chat_id,
)
return SendResult(
success=True,
message_id=None,
raw_response={"suppressed": _NO_REPLY_SENTINEL},
)

try:
# Check for a pending slash-command context. When the user ran a
Comment thread gateway/platforms/slack.py Outdated
Comment on lines +778 to +787
if isinstance(content, str) and content.strip() == _NO_REPLY_SENTINEL:
logger.debug(
"[Slack] Suppressed explicit NO_REPLY sentinel for channel %s",
chat_id,
)
return SendResult(
success=True,
message_id=None,
raw_response={"suppressed": _NO_REPLY_SENTINEL},
)
Comment on lines +18 to +40
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return

slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock

slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock

for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)

sys.modules.setdefault("aiohttp", MagicMock())
Comment thread gateway/platforms/slack.py Outdated
Comment on lines +778 to +787
if isinstance(content, str) and content.strip() == _NO_REPLY_SENTINEL:
logger.debug(
"[Slack] Suppressed explicit NO_REPLY sentinel for channel %s",
chat_id,
)
return SendResult(
success=True,
message_id=None,
raw_response={"suppressed": _NO_REPLY_SENTINEL},
)
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/slack Slack app adapter labels May 23, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competing fix for #30644 — closed PR #30646 addressed the same NO_REPLY sentinel leak with a similar approach (_is_no_reply_sentinel() guard). This PR takes a slightly different code path (inline regex-style check vs helper function). Maintainer should pick one.

@briandevans

Copy link
Copy Markdown
Contributor Author

Thanks for the cross-link, @alt-glitch — for context, #30646 was self-closed by @shawnpetros without review, so there's no active competing PR. The two approaches are functionally equivalent (module-level _NO_REPLY_SENTINEL constant + inline guard here vs. _is_no_reply_sentinel() helper there); happy to refactor to the helper-function form if reviewers prefer that style.

The two CI failures (build-arm64 and Scan PR for critical supply chain risks) are both GHA checkout-auth transients (fatal: could not read Username for 'https://github.com') — the actual Tests/Lint/Nix/test (1-6) checks all pass. Should clear on a rerun.

@shawnpetros

shawnpetros commented May 23, 2026

Copy link
Copy Markdown

To be fair, I think that only closed itself because of a visibility thing on my fork. Either way, would love to see this resolved upstream so I don't have to maintain the patch on my end.

@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All four findings addressed in ee354d628:

  1. Slash-context consumption before NO_REPLY return — the suppression branch now runs after _pop_slash_context, so it consumes any stashed slash response_url even when skipping the post. Previously a NO_REPLY turn following a native slash command (e.g. /q) left the context un-popped, so a later unrelated send() to the same (channel, user) pair could inherit it and be routed ephemerally to the wrong command.
  2. Dangling "Running /cmd…" ack — when a slash context was present and we're suppressing, the new _delete_slash_ephemeral helper POSTs delete_original: true to the response_url so the placeholder is removed rather than left up forever. Failures are non-fatal (ephemeral; expires client-side).
  3. Lingering typing indicator — the suppression branch now calls stop_typing(chat_id), symmetric with the normal post-send clear at the end of send().
  4. sys.modules.setdefault leak — the test fixture is now an autouse monkeypatch.setitem that only installs stubs for modules genuinely absent (importlib.util.find_spec probe, with ValueError/ModuleNotFoundError both treated as absent) and removes them after each test, so the stubs cannot leak into other gateway tests in the same run.

Also added regression test test_send_no_reply_consumes_slash_context_and_clears_typing that seeds a slash context, sends NO_REPLY, and asserts the context was popped, _delete_slash_ephemeral was called with the seeded response_url, stop_typing was called, and no public chat_postMessage was issued.

Verified locally with the full slack test set (282 passed, no leaks).

@briandevans briandevans force-pushed the fix/slack-no-reply-sentinel-30644 branch 9 times, most recently from 18543c2 to d9e217a Compare June 1, 2026 03:13
@briandevans briandevans force-pushed the fix/slack-no-reply-sentinel-30644 branch 6 times, most recently from 9d52775 to 0992c64 Compare June 3, 2026 03:15
@briandevans briandevans force-pushed the fix/slack-no-reply-sentinel-30644 branch 4 times, most recently from c5323ab to c29ff67 Compare June 4, 2026 03:19
@briandevans briandevans force-pushed the fix/slack-no-reply-sentinel-30644 branch 25 times, most recently from c110231 to 64e35bc Compare June 12, 2026 07:27
The Slack adapter posts whatever assistant text it is handed via ``send``,
including the explicit ``NO_REPLY`` sentinel some agents emit on group/thread
turns that should remain silent. That posts the literal "NO_REPLY" into the
channel, turning an intentional non-reply into visible clutter.

Match the precedent in ``gateway/platforms/feishu_comment.py``: when the
agent returns exactly ``NO_REPLY`` (after stripping surrounding whitespace),
short-circuit ``send`` with a ``SendResult(success=True, raw_response={
"suppressed": "NO_REPLY"})``. The check is exact post-strip so longer messages
that merely mention NO_REPLY ("I would not use NO_REPLY here") still post
normally; the guard is placed before the slash-command branch so ephemeral
slash replies are also covered.

Tests cover exact match, whitespace tolerance, the longer-prose case, the
empty-string fall-through, and the case-sensitive boundary.
Addresses Copilot review feedback on the NO_REPLY sentinel handling:

- gateway/platforms/slack.py: the suppression branch now runs AFTER
  ``_pop_slash_context`` so it consumes any stashed slash-command
  ``response_url`` even when skipping the post. Previously a NO_REPLY
  turn that followed a native slash command (e.g. ``/q``) left the
  ``response_url`` un-popped, so a later unrelated ``send()`` to the
  same channel/user pair would inherit the stale context and be
  routed ephemerally to the wrong command.
- gateway/platforms/slack.py: when suppressing after a slash command,
  call ``response_url`` with ``delete_original: true`` via a new
  ``_delete_slash_ephemeral`` helper so the dangling "Running /cmd…"
  placeholder is removed rather than left up forever.
- gateway/platforms/slack.py: also call ``stop_typing(chat_id)`` so
  the assistant "is thinking…" indicator (set by ``send_typing``) is
  cleared when the turn returns silence — symmetric with the normal
  post-send clear at the end of ``send()``.
- tests/gateway/test_slack_no_reply_sentinel.py: replaced the import-
  time ``sys.modules.setdefault`` shim (which could shadow real
  installed packages such as ``aiohttp``) with an autouse
  ``monkeypatch.setitem`` fixture that only installs stubs for modules
  that are genuinely absent and removes them after each test, so the
  stubs cannot leak into other gateway tests in the same run.
- tests/gateway/test_slack_no_reply_sentinel.py: added regression test
  ``test_send_no_reply_consumes_slash_context_and_clears_typing`` that
  seeds a slash context, sends ``NO_REPLY``, and asserts the context
  was popped, ``_delete_slash_ephemeral`` was called with the seeded
  ``response_url``, ``stop_typing`` was called, and no public
  ``chat_postMessage`` was issued.

Verified locally with the full slack test set (``test_slack.py``,
``test_slack_approval_buttons.py``, ``test_slack_channel_skills.py``,
``test_slack_mention.py``, ``test_slack_no_reply_sentinel.py``): 282
passed, no leaks.
@briandevans briandevans force-pushed the fix/slack-no-reply-sentinel-30644 branch from 64e35bc to 6e5afd6 Compare June 13, 2026 11:19
@briandevans

Copy link
Copy Markdown
Contributor Author

Quick status check three weeks on: this PR is still conflict-free against current main (GitHub reports it MERGEABLE) and CI is fully green — 24 checks passing, 4 skipped, 0 failures. The only other PR for #30644, #30646, remains closed-unmerged (self-closed by @shawnpetros, who noted above they'd prefer this resolved upstream), so this is the sole open fix for the NO_REPLY sentinel leak. Ready for review/merge whenever a maintainer has a slot.

@shawnpetros

Copy link
Copy Markdown

@briandevans thanks for keeping this warm. I re-checked the upstream state and I think this PR is still the right narrow fix for #30644.

What I checked just now:

Given that, my ask to maintainers is: please merge this one as the surgical Slack fix for #30644, and let the broader gateway-level silence-token cleanup proceed separately when its stack is ready. This closes a real Slack footgun without committing the repo to the larger design yet.

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

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/slack Slack app adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slack gateway should treat explicit no-reply sentinels as intentional silence

4 participants