diff --git a/gateway/display_config.py b/gateway/display_config.py index 8498c1df166..3b278745f45 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -47,6 +47,11 @@ _GLOBAL_DEFAULTS: dict[str, Any] = { "interim_assistant_messages": True, "long_running_notifications": True, "busy_ack_detail": True, + # Whether busy_input_mode=steer sends a visible "Steered into current run" + # acknowledgment after successfully injecting the user's mid-turn message. + # Disable when the platform should steer silently (the text still lands in + # the active run; only the confirmation echo is suppressed). + "busy_steer_ack_enabled": True, # When true, delete tool-progress / "⏳ Working — N min" / status bubbles # after the final response lands on platforms that support message # deletion (e.g. Telegram). Off by default — progress is still shown diff --git a/gateway/run.py b/gateway/run.py index f6c14f33ca4..b185ea558b1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1563,6 +1563,8 @@ if _config_path.exists(): os.environ["HERMES_GATEWAY_BUSY_TEXT_MODE"] = str(_display_cfg["busy_text_mode"]) if "busy_ack_enabled" in _display_cfg: os.environ["HERMES_GATEWAY_BUSY_ACK_ENABLED"] = str(_display_cfg["busy_ack_enabled"]) + if "busy_steer_ack_enabled" in _display_cfg: + os.environ["HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED"] = str(_display_cfg["busy_steer_ack_enabled"]) # Timezone: bridge config.yaml → HERMES_TIMEZONE env var. _tz_cfg = _cfg.get("timezone", "") if _tz_cfg and isinstance(_tz_cfg, str): @@ -5330,6 +5332,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("Busy ack suppressed for session %s", session_key) return True # input still processed, just no ack sent + from gateway.display_config import resolve_display_setting + platform_key = _platform_config_key(event.source.platform) + + # In steer mode the user's text has already been injected into the + # active run. Some mobile chat setups want that steering to be silent, + # like STT transcript echo suppression: keep the behavior, drop only + # the confirmation bubble. + if is_steer_mode: + steer_ack_env = os.environ.get("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED") + if steer_ack_env is not None: + steer_ack_enabled = steer_ack_env.strip().lower() in {"1", "true", "yes", "on"} + else: + steer_ack_enabled = bool( + resolve_display_setting( + _load_gateway_config(), + platform_key, + "busy_steer_ack_enabled", + True, + ) + ) + if not steer_ack_enabled: + logger.debug("Busy steer ack suppressed for session %s", session_key) + return True + # Debounce: only send an acknowledgment once every 30 seconds per session # to avoid spamming the user when they send multiple messages quickly _BUSY_ACK_COOLDOWN = 30 @@ -5343,7 +5369,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Build a status-rich acknowledgment. Mobile chat defaults keep this # terse; detailed iteration/tool state is still available in logs and # can be opted in per platform via display.platforms..busy_ack_detail. - from gateway.display_config import resolve_display_setting status_parts = [] busy_ack_detail_enabled = bool( resolve_display_setting( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5e0defc75fa..c9f267996c7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1675,6 +1675,10 @@ DEFAULT_CONFIG = { # behavior of showing tool-call summaries inline. "resume_skip_tool_only": True, "busy_input_mode": "interrupt", # interrupt | queue | steer + # When busy_input_mode="steer", suppress only the visible + # "Steered into current run" confirmation bubble by setting this false. + # The mid-turn steering itself still happens. + "busy_steer_ack_enabled": True, # Which interface bare `hermes` (and `hermes chat`) launches by default: # "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior) # "tui" — the modern Ink TUI (same as passing `--tui`) diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index a77c527d2e9..96a4684a13f 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -297,6 +297,67 @@ class TestBusySessionAck: assert "Steered" in content or "steer" in content.lower() assert "Interrupting" not in content + @pytest.mark.asyncio + async def test_steer_mode_can_suppress_visible_ack_without_disabling_steer(self, monkeypatch): + """busy_steer_ack_enabled=false keeps steering but drops the echo bubble.""" + import gateway.run as _gr + + monkeypatch.delenv("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED", raising=False) + monkeypatch.setattr( + _gr, + "_load_gateway_config", + lambda: {"display": {"platforms": {"telegram": {"busy_steer_ack_enabled": False}}}}, + ) + + runner, sentinel = _make_runner() + runner._busy_input_mode = "steer" + adapter = _make_adapter() + + event = _make_event(text="also check the tests") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + agent.steer = MagicMock(return_value=True) + runner._running_agents[sk] = agent + + await runner._handle_active_session_busy_message(event, sk) + + agent.steer.assert_called_once_with("also check the tests") + agent.interrupt.assert_not_called() + adapter._send_with_retry.assert_not_called() + assert sk not in adapter._pending_messages + + @pytest.mark.asyncio + async def test_steer_ack_env_override_can_suppress_visible_ack(self, monkeypatch): + """Env override supports process-level suppression for gateway services.""" + import gateway.run as _gr + + monkeypatch.setenv("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED", "false") + monkeypatch.setattr( + _gr, + "_load_gateway_config", + lambda: {"display": {"platforms": {"telegram": {"busy_steer_ack_enabled": True}}}}, + ) + + runner, sentinel = _make_runner() + runner._busy_input_mode = "steer" + adapter = _make_adapter() + + event = _make_event(text="steer silently") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + agent.steer = MagicMock(return_value=True) + runner._running_agents[sk] = agent + + await runner._handle_active_session_busy_message(event, sk) + + agent.steer.assert_called_once_with("steer silently") + adapter._send_with_retry.assert_not_called() + assert sk not in adapter._pending_messages + @pytest.mark.asyncio async def test_steer_mode_falls_back_to_queue_when_agent_rejects(self): """If agent.steer() returns False, fall back to queue behavior."""