diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index e9464365c1..a1fef589a2 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -484,6 +484,9 @@ class BasePlatformAdapter(ABC): self._background_tasks: set[asyncio.Task] = set() # Chats where auto-TTS on voice input is disabled (set by /voice off) self._auto_tts_disabled_chats: set = set() + # Chats where typing indicator is paused (e.g. during approval waits). + # _keep_typing skips send_typing when the chat_id is in this set. + self._typing_paused: set = set() @property def has_fatal_error(self) -> bool: @@ -943,10 +946,16 @@ class BasePlatformAdapter(ABC): Telegram/Discord typing status expires after ~5 seconds, so we refresh every 2 to recover quickly after progress messages interrupt it. + + Skips send_typing when the chat is in ``_typing_paused`` (e.g. while + the agent is waiting for dangerous-command approval). This is critical + for Slack's Assistant API where ``assistant_threads_setStatus`` disables + the compose box — pausing lets the user type ``/approve`` or ``/deny``. """ try: while True: - await self.send_typing(chat_id, metadata=metadata) + if chat_id not in self._typing_paused: + await self.send_typing(chat_id, metadata=metadata) await asyncio.sleep(interval) except asyncio.CancelledError: pass # Normal cancellation when handler completes @@ -960,7 +969,20 @@ class BasePlatformAdapter(ABC): await self.stop_typing(chat_id) except Exception: pass - + self._typing_paused.discard(chat_id) + + def pause_typing_for_chat(self, chat_id: str) -> None: + """Pause typing indicator for a chat (e.g. during approval waits). + + Thread-safe (CPython GIL) — can be called from the sync agent thread + while ``_keep_typing`` runs on the async event loop. + """ + self._typing_paused.add(chat_id) + + def resume_typing_for_chat(self, chat_id: str) -> None: + """Resume typing indicator for a chat after approval resolves.""" + self._typing_paused.discard(chat_id) + # ── Processing lifecycle hooks ────────────────────────────────────────── # Subclasses override these to react to message processing events # (e.g. Discord adds 👀/✅/❌ reactions). diff --git a/gateway/run.py b/gateway/run.py index f6fb563cac..f105282566 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5434,6 +5434,11 @@ class GatewayRunner: if not count: return "No pending command to approve." + # Resume typing indicator — agent is about to continue processing. + _adapter = self.adapters.get(source.platform) + if _adapter: + _adapter.resume_typing_for_chat(source.chat_id) + count_msg = f" ({count} commands)" if count > 1 else "" logger.info("User approved %d dangerous command(s) via /approve%s", count, scope_msg) return f"✅ Command{'s' if count > 1 else ''} approved{scope_msg}{count_msg}. The agent is resuming..." @@ -5466,6 +5471,11 @@ class GatewayRunner: if not count: return "No pending command to deny." + # Resume typing indicator — agent continues (with BLOCKED result). + _adapter = self.adapters.get(source.platform) + if _adapter: + _adapter.resume_typing_for_chat(source.chat_id) + count_msg = f" ({count} commands)" if count > 1 else "" logger.info("User denied %d dangerous command(s) via /deny", count) return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}." @@ -6759,6 +6769,15 @@ class GatewayRunner: UX. Otherwise fall back to a plain text message with ``/approve`` instructions. """ + # Pause the typing indicator while the agent waits for + # user approval. Critical for Slack's Assistant API where + # assistant_threads_setStatus disables the compose box — the + # user literally cannot type /approve while "is thinking..." + # is active. The approval message send auto-clears the Slack + # status; pausing prevents _keep_typing from re-setting it. + # Typing resumes in _handle_approve_command/_handle_deny_command. + _status_adapter.pause_typing_for_chat(_status_chat_id) + cmd = approval_data.get("command", "") desc = approval_data.get("description", "dangerous command")