hermes - ✅(Solved) Fix [Bug]: Slack slash commands (/q, /btw, etc.) produce no "Only visible to you" acknowledgement [2 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#18182Fetched 2026-05-02 05:50:03
View on GitHub
Comments
0
Participants
1
Timeline
9
Reactions
0
Participants
Timeline (top)
labeled ×4cross-referenced ×2referenced ×2closed ×1

Error Message

try: _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None response = await self._message_handler(event) if response: await self._send_with_retry( chat_id=event.source.chat_id, content=response, reply_to=event.message_id, metadata=_thread_meta, ) except Exception as e: logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True) return

Root Cause

Two separate gaps combine to produce this behaviour.

Fix Action

Fix / Workaround

When /q is invoked against an already-active session, gateway/platforms/base.py lines 2436-2448 dispatches the command and posts the return string:

try:
    _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
    response = await self._message_handler(event)
    if response:
        await self._send_with_retry(
            chat_id=event.source.chat_id,
            content=response,
            reply_to=event.message_id,
            metadata=_thread_meta,
        )
except Exception as e:
    logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return
  1. Ephemeral command-reply routing. Slack slash command payloads include a response_url that can be POSTed to (with {"response_type": "ephemeral", "text": "…", "replace_original": true}) for up to 30 minutes after the invocation. Preserve the (response_url, user_id) tuple alongside the dispatched event so that when base.py calls _send_with_retry(chat_id=…, content="Queued for the next turn.", …) the Slack adapter can detect "this reply is for a just-dispatched slash command" and route via response_url (or fall back to chat.postEphemeral) instead of chat.postMessage.

PR fix notes

PR #18198: fix(gateway/slack): ephemeral slash-command ack, private notice delivery, format_message fixes

Description (problem / solution / changelog)

Summary

Fixes #18182 and salvages #9340 — comprehensive Slack ephemeral messaging improvements:

  1. Slash commands now show ephemeral acknowledgements (/q, /btw, /stop, /model, etc.) and route command replies ephemerally, matching Discord's behavior.
  2. Operational notices (e.g. sethome prompt) can now be delivered privately via chat_postEphemeral when slack.notice_delivery: private is configured.
  3. format_message bug fixes — markdown images no longer produce broken Slack links, and literal asterisks with spaces (a * b * c) are no longer mistakenly italicized.

Changes

Commit 1: fix(gateway/slack): ephemeral ack and routing for slash commands

Fixes #18182 — Two gaps combined to produce the bug:

Gap 1 fix — Immediate ephemeral ack: handle_hermes_command now passes response_type="ephemeral" and "Running /cmd…" text to ack(). Previously the bare await ack() sent an empty 200 OK, which Slack silently swallowed.

Gap 2 fix — Ephemeral reply routing via response_url:

  • _handle_slash_command stashes the Slack response_url from the command payload in _slash_command_contexts (keyed by (channel_id, user_id)) before dispatching.
  • send() checks for a pending slash context. When found, POSTs to response_url with replace_original: true to swap the ack with the real reply, keeping it ephemeral.
  • Stale contexts garbage-collected on lookup (120s TTL). Non-fatal fallback if POST fails.

Commit 2: feat(gateway): private notice delivery and Slack format_message fixes

Salvaged from PR #9340 by @probepark. Cherry-picked onto current main with original authorship preserved.

FileWhat
gateway/config.py_normalize_notice_delivery() + GatewayConfig.get_notice_delivery() with per-platform config bridging
gateway/platforms/base.pysend_private_notice() default implementation (falls through to send())
gateway/platforms/slack.pysend_private_notice() via chat_postEphemeral
gateway/run.py_deliver_platform_notice() helper replaces direct adapter.send() for the sethome notice, with private→public fallback
gateway/platforms/slack.pyapp_mention handler now forwards to _handle_slack_message (safe due to ts-based dedup) instead of no-op
gateway/platforms/slack.pyformat_message: negative lookbehind prevents markdown images from becoming broken Slack links; italic regex requires non-whitespace boundaries

Commit 3: chore: add probepark to AUTHOR_MAP

Commit 4: fix(gateway/slack): review fixes — scope ephemeral to commands, user isolation

Self-review caught and fixed:

  1. Critical — Free-form /hermes <question> routed agent reply ephemeral. The context was stashed unconditionally, so /hermes what's the weather would make the full agent response invisible to the channel. Fix: Only stash when text.startswith("/").

  2. Critical — Concurrent users on same channel could steal each other's ephemeral context. _pop_slash_context scanned by channel_id only, so User B's response could consume User A's response_url. Fix: Added a ContextVar (_slash_user_id) that threads the invoking user's ID from _handle_slash_command through to send(). _pop_slash_context now matches the exact (channel_id, user_id) key. ContextVars propagate to child asyncio.Tasks, so the value survives through handle_message_process_message_background_send_with_retrysend().

  3. Medium — _send_slash_ephemeral skipped truncate_message(). Long responses could silently fail. Fixed.

  4. Warning — Bare except Exception: pass in _deliver_platform_notice. Fixed: Logs at debug level.

  5. Docs — app_mention dedup dependency on shared event ts. Added comment.

Files changed

FileLinesWhat
gateway/platforms/slack.py+197/-7Ephemeral ack, response_url routing, ContextVar isolation, send_private_notice, app_mention fix, format_message fixes
gateway/config.py+25Notice delivery config normalization and bridging
gateway/platforms/base.py+20send_private_notice() abstract with fallback
gateway/run.py+65/-25_deliver_platform_notice() helper with debug logging
tests/gateway/test_slack.py+31614 ephemeral ack tests (incl. concurrent-user isolation + free-form question regression) + 3 notice/format tests
tests/gateway/test_config.py+36Notice delivery config bridging tests
tests/gateway/test_notice_delivery.py+67Private notice delivery E2E tests (new file)
scripts/release.py+2AUTHOR_MAP entry for probepark

Test plan

  • 14 new slash ephemeral tests (concurrent-user isolation, free-form question regression, stash/pop/TTL, response_url routing, fallback)
  • 3 notice delivery tests (private, fallback-to-public, default public)
  • 3 format_message tests (markdown image, literal asterisks, send_private_notice)
  • Full gateway suite: 210 passed (targeted), 4316 passed (full)
  • py_compile verified on all modified files

Changed files

  • gateway/config.py (modified, +25/-0)
  • gateway/platforms/base.py (modified, +20/-0)
  • gateway/platforms/slack.py (modified, +199/-9)
  • gateway/run.py (modified, +47/-18)
  • scripts/release.py (modified, +2/-0)
  • tests/gateway/test_config.py (modified, +36/-0)
  • tests/gateway/test_notice_delivery.py (added, +67/-0)
  • tests/gateway/test_slack.py (modified, +316/-0)

PR #9340: fix(gateway): support private notice delivery on Slack

Description (problem / solution / changelog)

What does this PR do?

This PR fixes Slack setup and operational notices so they are delivered privately when the platform supports it, instead of always being posted into the public channel/thread.

Previously, Hermes handled Slack ephemeral notice delivery inside Slack-specific code paths. That made the behavior hard to extend and meant notice visibility policy was effectively hardcoded at the platform implementation layer.

This PR introduces a platform-level notice delivery abstraction and routes Slack app_mention events through the normal message pipeline so private notice delivery works consistently in real Slack conversations.

On Slack, slack.notice_delivery: private now prefers ephemeral sends when possible and falls back to the existing public behavior when ephemeral delivery is unavailable.

Related Issue

N/A

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

  • Added platform-level notice_delivery config bridging in gateway/config.py
  • Generalized private notice sending in gateway/platforms/base.py and gateway/run.py
  • Updated Slack behavior in gateway/platforms/slack.py to:
    • prefer ephemeral/private notices when configured
    • preserve fallback-to-public behavior when private delivery is not possible
    • route app_mention events through the normal pipeline so notice behavior is applied consistently
  • Added regression coverage for:
    • config bridging (tests/gateway/test_config.py)
    • private notice delivery behavior (tests/gateway/test_notice_delivery.py)
    • Slack platform behavior (tests/gateway/test_slack.py)

How to Test

  1. Configure Slack with:
slack:
  notice_delivery: private
  1. Start Hermes gateway with Slack connected.
  2. Trigger a setup/operational notice in Slack.
  3. Verify that:
    • Hermes attempts private/ephemeral delivery first
    • public fallback still works when ephemeral delivery is unavailable
  4. Verify app_mention events still flow through the normal message pipeline.
  5. Run regression tests:
python -m pytest tests/gateway/test_config.py tests/gateway/test_notice_delivery.py tests/gateway/test_slack.py -q

Expected result:

  • all tests pass
  • Slack notices are private when supported
  • existing public fallback behavior remains intact

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/refactor
  • I've run pytest tests/ -q and all tests pass
  • I've run targeted regression tests for the affected gateway paths
  • I've added tests for my changes
  • I've tested on macOS

Documentation & Housekeeping

  • No user-facing docs required for merge; config is covered by tests and code paths
  • No new config example changes required in cli-config.yaml.example — N/A
  • No architecture/workflow docs required beyond the code/test updates — N/A
  • Considered cross-platform impact at the gateway abstraction layer
  • No tool schema changes required — N/A

Screenshots / Logs

N/A — behavior is covered by targeted regression tests and live Slack verification.

Changed files

  • gateway/config.py (modified, +25/-0)
  • gateway/platforms/base.py (modified, +20/-0)
  • gateway/platforms/slack.py (modified, +46/-7)
  • gateway/run.py (modified, +43/-18)
  • tests/gateway/test_config.py (modified, +36/-0)
  • tests/gateway/test_notice_delivery.py (added, +67/-0)
  • tests/gateway/test_slack.py (modified, +32/-0)

Code Example

await interaction.response.defer(ephemeral=True)          # ← immediate ephemeral ack
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
if followup_msg:
    await interaction.edit_original_response(content=followup_msg)  # ← "Queued for the next turn."
else:
    await interaction.delete_original_response()

---

@self._app.command(_slash_pattern)
async def handle_hermes_command(ack, command):
    await ack()
    await self._handle_slash_command(command)

---

slash = (command.get("command") or "").lstrip("/")
await ack(response_type="ephemeral", text=f"Running `/{slash}`…")

---

try:
    _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
    response = await self._message_handler(event)
    if response:
        await self._send_with_retry(
            chat_id=event.source.chat_id,
            content=response,
            reply_to=event.message_id,
            metadata=_thread_meta,
        )
except Exception as e:
    logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return

---

@self._app.command(_slash_pattern)
   async def handle_hermes_command(ack, command):
       slash = (command.get("command") or "").lstrip("/")
       # Show the invoking user an immediate "Only visible to you" ack.
       # Satisfies the 3-second SLA and gives visible feedback that
       # Slack received the command, matching Discord's behaviour.
       await ack(response_type="ephemeral", text=f"Running `/{slash}`…")
       await self._handle_slash_command(command)
RAW_BUFFERClick to expand / collapse

Bug Description

When a user runs a native slash command on Slack — e.g. /q <message>, /btw, /stop, /model, or any other command from COMMAND_REGISTRY — Slack shows no acknowledgement at all. There is no "Only visible to you" ephemeral confirmation the way Discord shows a deferred ephemeral "thinking…" indicator. From the user's perspective the command appears to vanish: they see their own slash input disappear from the composer, but nothing is posted back confirming the bot received or is processing it.

On Discord the same invocation (e.g. /queue foo) shows an ephemeral "Hermes is thinking…" bubble immediately, which is then edited to Queued for the next turn. once the command resolves. Slack gets nothing equivalent.

For fast commands like /q <prompt> this is especially confusing because the "Queued for the next turn." reply that the gateway does eventually produce is posted as a regular public channel message via chat.postMessage, not as an ephemeral response to the slash invocation.

Steps to Reproduce

  1. Configure Hermes with a Slack workspace (Socket Mode, manifest includes /q via hermes slack manifest).
  2. In any Slack channel where the bot is installed, trigger an agent turn that keeps the session busy (e.g. ask a long-running question).
  3. While the agent is running, type /q follow-up idea and press Enter.
  4. Observe Slack.

Expected Behavior

Match Discord's behaviour:

  • Slack shows an immediate ephemeral "Only visible to you" acknowledgement (e.g. Running /q… or Working on /q…) to the invoking user.
  • The command's return value (Queued for the next turn.) is then delivered ephemerally to the same user — either by replacing the initial ack via response_url, or via chat.postEphemeral.

Ref Discord's equivalent in gateway/platforms/discord.py (_run_simple_slash):

await interaction.response.defer(ephemeral=True)          # ← immediate ephemeral ack
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
if followup_msg:
    await interaction.edit_original_response(content=followup_msg)  # ← "Queued for the next turn."
else:
    await interaction.delete_original_response()

Actual Behavior

Slack produces no acknowledgement. The eventual Queued for the next turn. reply (when it arrives at all) is posted as a regular public message in the channel, visible to everyone.

Root Cause Analysis

Two separate gaps combine to produce this behaviour.

Gap 1 — Empty ack() in the Slack slash handler

gateway/platforms/slack.py lines 503-506 (on main @ b29b709):

@self._app.command(_slash_pattern)
async def handle_hermes_command(ack, command):
    await ack()
    await self._handle_slash_command(command)

A bare await ack() in slack_bolt sends an empty HTTP 200 back to Slack. That satisfies the 3-second SLA but produces no message — not even ephemeral. Slack does not auto-echo the command the way Discord does when an interaction is deferred ephemerally, so the user sees nothing.

The fix here is to pass text and an ephemeral response type to ack:

slash = (command.get("command") or "").lstrip("/")
await ack(response_type="ephemeral", text=f"Running `/{slash}`…")

(slack_bolt async Ack reference.)

Gap 2 — Command return values post publicly, not ephemerally

When /q is invoked against an already-active session, gateway/platforms/base.py lines 2436-2448 dispatches the command and posts the return string:

try:
    _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
    response = await self._message_handler(event)
    if response:
        await self._send_with_retry(
            chat_id=event.source.chat_id,
            content=response,
            reply_to=event.message_id,
            metadata=_thread_meta,
        )
except Exception as e:
    logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return

_message_handler returns "Queued for the next turn." (from gateway/run.py line 4502). _send_with_retry on the Slack adapter ultimately calls chat.postMessage — a public channel message — with no awareness that the originating event was a slash command and therefore eligible for an ephemeral reply.

A search of gateway/platforms/slack.py shows zero uses of chat_postEphemeral or ack(response_type=…) for slash-command replies anywhere.

Why this isn't just a /q issue

Because handle_hermes_command is registered for every native slash (all of COMMAND_REGISTRY is surfaced via slack_native_slashes() in hermes_cli/commands.py), the same gap affects /btw, /stop, /new, /model, /status, and so on. Discord's equivalent handlers all defer ephemerally, so the two platforms visibly diverge.

Why the legacy /hermes <subcommand> form has the same problem

The legacy entry point at line 2507 of slack.py (if slash_name in ("hermes", ""):) funnels into the same handle_hermes_command wrapper and so inherits the bare await ack(). Both code paths need the fix.

Proposed Fix

Two coordinated changes in gateway/platforms/slack.py:

  1. Immediate ephemeral ack in the slash command handler:

    @self._app.command(_slash_pattern)
    async def handle_hermes_command(ack, command):
        slash = (command.get("command") or "").lstrip("/")
        # Show the invoking user an immediate "Only visible to you" ack.
        # Satisfies the 3-second SLA and gives visible feedback that
        # Slack received the command, matching Discord's behaviour.
        await ack(response_type="ephemeral", text=f"Running `/{slash}`…")
        await self._handle_slash_command(command)
  2. Ephemeral command-reply routing. Slack slash command payloads include a response_url that can be POSTed to (with {"response_type": "ephemeral", "text": "…", "replace_original": true}) for up to 30 minutes after the invocation. Preserve the (response_url, user_id) tuple alongside the dispatched event so that when base.py calls _send_with_retry(chat_id=…, content="Queued for the next turn.", …) the Slack adapter can detect "this reply is for a just-dispatched slash command" and route via response_url (or fall back to chat.postEphemeral) instead of chat.postMessage.

    A minimal plumbing approach: stash {response_url, user_id, invoked_at} on MessageEvent.raw_message (already command — the Slack payload includes both fields) and teach the Slack adapter's send_message / _send_with_retry to consult that context when posting a reply to a MessageType.COMMAND event within the 30-minute response_url window.

Additional notes

  • queue/q is in ACTIVE_SESSION_BYPASS_COMMANDS in hermes_cli/commands.py, so when an agent is already running the command dispatch path in base.py (lines 2438-2445) is what runs. For an idle session /q falls through to _start_session_processing, which runs a full agent turn on the command itself — in that case there's not even the "Queued for the next turn." string to send back, and the user sits with no visible feedback until the whole turn finishes. The ephemeral ack in the slash handler (Gap 1 fix) covers both branches; the response_url routing (Gap 2 fix) matters most for the active-session branch.
  • Cross-reference: Discord's _run_simple_slash at gateway/platforms/discord.py line 2273-2313 is the existing shape to match.

Environment

  • Hermes Agent main @ b29b709a71273cccbd9752035acb8104dc5d7cc5 (2026-05-01).
  • Reproduced in any Slack workspace using Socket Mode + the generated hermes slack manifest.
  • Bug is platform-only (Slack); Discord and Telegram are unaffected.

PR-ready

Not yet — this is a scoping issue first. Happy to pair with whoever takes it on.

extent analysis

TL;DR

To fix the issue of Slack not showing an acknowledgement for native slash commands, modify the handle_hermes_command function in gateway/platforms/slack.py to send an immediate ephemeral acknowledgement and update the command reply routing to use the response_url for ephemeral responses.

Guidance

  • Update the handle_hermes_command function to send an immediate ephemeral acknowledgement using await ack(response_type="ephemeral", text=f"Running /{slash}…").
  • Modify the command reply routing in base.py to use the response_url for ephemeral responses when the reply is for a slash command.
  • Preserve the (response_url, user_id) tuple alongside the dispatched event to enable ephemeral reply routing.
  • Teach the Slack adapter's send_message / _send_with_retry to consult the stored context when posting a reply to a MessageType.COMMAND event.

Example

@self._app.command(_slash_pattern)
async def handle_hermes_command(ack, command):
    slash = (command.get("command") or "").lstrip("/")
    await ack(response_type="ephemeral", text=f"Running `/{slash}`…")
    await self._handle_slash_command(command)

Notes

  • The fix requires two coordinated changes in gateway/platforms/slack.py.
  • The response_url routing fix is necessary to ensure that command replies are sent as ephemeral messages.
  • The issue is specific to Slack and does not affect Discord or Telegram.

Recommendation

Apply the proposed fix to update the handle_hermes_command function and command reply routing to use the response_url for ephemeral responses, ensuring that Slack shows an acknowledgement for native slash commands.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING