From cd9a9cd8e5e12daed968360f70d28e749c6c1fa0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:30:01 -0700 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20Slack=20approval=20UX=20in=20th?= =?UTF-8?q?reads=20=E2=80=94=20block-size=20overflow=20+=20typed-prefix=20?= =?UTF-8?q?instruction=20text=20(#43444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the reported Slack thread approval UX: 1. Slack Block Kit approval/confirm sends silently overflowed the 3000-char section-block cap (flat 2900-char truncation + header + reason), so long execute_code approvals failed with invalid_blocks and fell back to the plain-text prompt with no buttons. Budget the command preview against the rendered fixed parts so blocks never exceed the cap (send_exec_approval + send_slash_confirm). 2. The text fallbacks told users to reply /approve — which Slack blocks inside threads and Matrix clients reserve client-side. Add a typed_command_prefix capability flag on BasePlatformAdapter (default "/"; Slack and Matrix set "!" to match their existing bang-prefix rewrite) and use it in the shared fallback prompt builders (exec approval, update prompt, destructive slash confirm, expensive-model confirm) plus Matrix's reaction-prompt text. The slash-confirm text-intercept now also accepts bang-prefixed replies (!always, !cancel) since those keywords aren't registered commands and the adapters' rewrite doesn't touch them. --- gateway/platforms/base.py | 12 ++++++++ gateway/platforms/matrix.py | 13 ++++++--- gateway/platforms/slack.py | 33 ++++++++++++++++------ gateway/run.py | 28 ++++++++++++------ gateway/slash_commands.py | 16 ++++++++++- website/docs/user-guide/messaging/slack.md | 5 ++++ 6 files changed, 86 insertions(+), 21 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2d940499e26..8a71c75a30c 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1804,6 +1804,18 @@ class BasePlatformAdapter(ABC): # preview (see gateway/run.py progress_callback). supports_code_blocks: bool = False + # The command prefix users can always TYPE on this platform to reach + # Hermes commands. Default "/" (most platforms deliver "/approve" etc. + # as plain message text). Platforms where typing a leading "/" is + # intercepted or restricted by the client (Slack blocks native slash + # commands inside threads; Matrix clients reserve "/" for client-local + # commands) ship a "!" alias rewrite in their adapter and set this to + # "!" so user-facing instruction text ("Reply `!approve` ...") tells + # users the form that actually works everywhere. Capability flag — + # shared prompt builders read it via getattr(adapter, + # "typed_command_prefix", "/"); no per-platform branching at call sites. + typed_command_prefix: str = "/" + def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index e885afc9337..b00fe5effc6 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -422,6 +422,11 @@ class MatrixAdapter(BasePlatformAdapter): supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown) + # Matrix clients commonly reserve typed "/" for client-local commands; + # the adapter accepts "!command" as the alias that always reaches Hermes + # (see _normalize_matrix_bang_command), so instruction text shows "!". + typed_command_prefix = "!" + # Threshold for detecting Matrix client-side message splits. # When a chunk is near the ~4000-char practical limit, a continuation # is almost certain. @@ -1350,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter): "⚠️ **Dangerous command requires approval**\n" f"```\n{cmd_preview}\n```\n" f"Reason: {description}\n\n" - "Reply `/approve` to execute, `/approve session` to approve this pattern for the session, " - "`/approve always` to approve permanently, or `/deny` to cancel.\n\n" + "Reply `!approve` to execute, `!approve session` to approve this pattern for the session, " + "`!approve always` to approve permanently, or `!deny` to cancel.\n\n" "You can also click the reaction to approve:\n" - "✅ = /approve\n" - "❎ = /deny" + "✅ = approve\n" + "❎ = deny" ) result = await self.send(chat_id, text, metadata=metadata) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 0e1b055ea50..1224922271a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter): MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin supports_code_blocks = True # Slack mrkdwn renders fenced code blocks + # Slack blocks typed native slash commands inside threads ("/approve is + # not supported in threads. Sorry!"). The adapter rewrites a leading + # "!" to "/" for known commands (see _handle_slack_message), so "!" is + # the prefix that works everywhere — instruction text must show it. + typed_command_prefix = "!" def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SLACK) @@ -2692,19 +2697,26 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") try: - cmd_preview = command[:2900] + "..." if len(command) > 2900 else command thread_ts = self._resolve_thread_ts(None, metadata) + # Slack hard-caps a section block's text at 3000 chars; an + # oversized block fails the whole send with ``invalid_blocks`` + # and the gateway falls back to the plain-text prompt (no + # buttons). execute_code approvals embed the entire script in + # ``command``, so budget the preview against the fixed parts + # instead of a flat truncation that overflows once the header + + # reason are added. + header = ":warning: *Command Approval Required*\n" + reason = f"Reason: {description[:500]}" + budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...") + cmd_preview = command[:budget] + "..." if len(command) > budget else command + blocks = [ { "type": "section", "text": { "type": "mrkdwn", - "text": ( - f":warning: *Command Approval Required*\n" - f"```{cmd_preview}```\n" - f"Reason: {description}" - ), + "text": f"{header}```{cmd_preview}```\n{reason}", }, }, { @@ -2772,8 +2784,13 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") try: - body = message[:2900] + "..." if len(message) > 2900 else message thread_ts = self._resolve_thread_ts(None, metadata) + # Same 3000-char section-block cap as send_exec_approval: budget + # the body against the rendered title so the wrapper never pushes + # the block over the limit (overflow → invalid_blocks → no buttons). + _title = (title or "Confirm")[:150] + budget = 3000 - len(f"*{_title}*\n\n") - len("...") + body = message[:budget] + "..." if len(message) > budget else message # Encode session_key and confirm_id into the button value so the # callback handler can resolve without extra bookkeeping. value = f"{session_key}|{confirm_id}" @@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter): "type": "section", "text": { "type": "mrkdwn", - "text": f"*{title or 'Confirm'}*\n\n{body}", + "text": f"*{_title}*\n\n{body}", }, }, { diff --git a/gateway/run.py b/gateway/run.py index 16623e25385..9e30c923c8b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6473,6 +6473,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _tool_approval_live = False if _pending_confirm and not _tool_approval_live: _raw_reply = (event.text or "").strip() + # Accept bang-prefixed replies (`!always`, `!cancel`) verbatim. + # Slack/Matrix instruction text shows the `!` prefix (typed `/` + # is blocked in Slack threads), but the adapters only rewrite + # `!` — `always`/`cancel` are confirm keywords, + # not registered commands, so the `!` survives to here. + _norm_reply = _raw_reply.lstrip("!/").lower() _cmd_reply = event.get_command() _confirm_choice = None if _cmd_reply in {"approve", "yes", "ok", "confirm"}: @@ -6481,11 +6487,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _confirm_choice = "always" elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}: _confirm_choice = "cancel" - elif _raw_reply.lower() in {"approve", "approve once", "once"}: + elif _norm_reply in {"approve", "approve once", "once"}: _confirm_choice = "once" - elif _raw_reply.lower() in {"always", "always approve"}: + elif _norm_reply in {"always", "always approve"}: _confirm_choice = "always" - elif _raw_reply.lower() in {"cancel", "nevermind", "no"}: + elif _norm_reply in {"cancel", "nevermind", "no"}: _confirm_choice = "cancel" if _confirm_choice is not None: _resolved = await _slash_confirm_mod.resolve( @@ -10628,6 +10634,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return result return result + _p = self._typed_command_prefix_for(event.source.platform) prompt_message = ( f"⚠️ **Confirm /{command}**\n\n" f"{detail}\n\n" @@ -10635,7 +10642,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "• **Approve Once** — proceed this time only\n" "• **Always Approve** — proceed and silence this prompt permanently\n" "• **Cancel** — keep current conversation\n\n" - "_Text fallback: reply `/approve`, `/always`, or `/cancel`._" + f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._" ) return await self._request_slash_confirm( event=event, @@ -11030,11 +11037,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("Button-based update prompt failed: %s", btn_err) if not sent_buttons: default_hint = f" (default: {default})" if default else "" + _p = getattr(adapter, "typed_command_prefix", "/") await adapter.send( chat_id, f"⚕ **Update needs your input:**\n\n" f"{prompt_text}{default_hint}\n\n" - f"Reply `/approve` (yes) or `/deny` (no), " + f"Reply `{_p}approve` (yes) or `{_p}deny` (no), " f"or type your answer directly.", metadata=metadata, ) @@ -14101,14 +14109,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Button-based approval failed, falling back to text: %s", _e ) - # Fallback: plain text approval prompt + # Fallback: plain text approval prompt. Use the adapter's + # typed prefix so Slack/Matrix users are told the form they + # can actually type (`!approve`) — typed "/" is blocked in + # Slack threads and reserved by Matrix clients. + _p = getattr(_status_adapter, "typed_command_prefix", "/") cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd msg = ( f"⚠️ **Dangerous command requires approval:**\n" f"```\n{cmd_preview}\n```\n" f"Reason: {desc}\n\n" - f"Reply `/approve` to execute, `/approve session` to approve this pattern " - f"for the session, `/approve always` to approve permanently, or `/deny` to cancel." + f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern " + f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel." ) try: _approval_send_fut = safe_schedule_threadsafe( diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index ac210d367de..25a3a868a76 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -47,6 +47,19 @@ logger = logging.getLogger("gateway.run") class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" + def _typed_command_prefix_for(self, platform) -> str: + """Return the prefix users can always type to reach Hermes commands. + + Reads the adapter's ``typed_command_prefix`` capability flag + (default "/"). Slack and Matrix return "!" because typed "/" + commands are blocked in Slack threads / reserved by Matrix clients; + their adapters rewrite "!command" to "/command" on receive. + Instruction text built for those platforms must show the prefix + that actually works when typed. + """ + adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None + return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/" + async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /new or /reset command.""" source = event.source @@ -1324,13 +1337,14 @@ class GatewaySlashCommandsMixin: # an explicit decision). return await _finish_switch() + _p = self._typed_command_prefix_for(event.source.platform) return await self._request_slash_confirm( event=event, command="model", title="Expensive Model Warning", message=( f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n" - "_Text fallback: reply `/approve` to switch or `/cancel` to keep " + f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep " "the current model._" ), handler=_on_cost_confirm, diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index db32fcc4dea..cf281202f7a 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -280,6 +280,11 @@ thread. Only the first token is checked against the known command list, so casual messages like `!nice work` pass through to the agent unchanged. +Approval prompts (dangerous command / `execute_code` approval) normally +render as interactive buttons. When buttons can't be delivered and +Hermes falls back to a text prompt, the prompt instructs you to reply +with `!approve` / `!deny` — the form that works inside threads. + ### Advanced: emit only the slash-commands array If you maintain your Slack manifest by hand and just want the slash