From dc0c778b22be86d4d4c899c49af0cfe01defa9dd Mon Sep 17 00:00:00 2001 From: George Drury <1198104+georgedrury@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:10:24 +0100 Subject: [PATCH] feat(gateway): make the working-state status text configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PlatformConfig.typing_status_text for the two platforms that render text for the working-state line: Slack's assistant.threads.setStatus status (hardcoded 'is thinking...') and Google Chat's visible marker message (hardcoded 'Hermes is thinking…'). None keeps each platform's built-in default; to_dict omits the field when unset so existing configs serialize unchanged. Plumbing mirrors typing_indicator exactly (typed field, from_dict extra fallback, shared-key bridge). Also documents that Slack's status line requires the assistant:write scope — without it setStatus fails silently and Slack shows its own generic placeholder, which previously made the behaviour undiagnosable from config alone. --- gateway/config.py | 20 ++++++++++++++++ plugins/platforms/google_chat/adapter.py | 5 +++- plugins/platforms/slack/adapter.py | 6 +++-- tests/gateway/test_config.py | 22 ++++++++++++++++++ tests/gateway/test_google_chat.py | 25 ++++++++++++++++++++ tests/gateway/test_slack.py | 18 +++++++++++++++ website/docs/user-guide/messaging/slack.md | 27 ++++++++++++++++++++++ 7 files changed, 120 insertions(+), 3 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index e526445f3242..beaaa3f44696 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -573,6 +573,15 @@ class PlatformConfig: # gateway/platforms/base.py. typing_indicator: bool = True + # Custom text for the working-state line on platforms whose typing + # indicator renders text rather than a native bubble: Slack's + # assistant.threads.setStatus line (shown next to the bot name; needs the + # assistant:write scope to render) and Google Chat's visible marker + # message. None keeps each platform's built-in default ("is thinking..." / + # "Hermes is thinking…"). Platforms with textless indicators (Discord, + # Telegram, Matrix, …) ignore it. + typing_status_text: Optional[str] = None + # Per-channel model/provider/system_prompt overrides (channel_id -> ChannelOverride) channel_overrides: Dict[str, ChannelOverride] = field(default_factory=dict) @@ -587,6 +596,8 @@ class PlatformConfig: "gateway_restart_notification": self.gateway_restart_notification, "typing_indicator": self.typing_indicator, } + if self.typing_status_text is not None: + result["typing_status_text"] = self.typing_status_text if self.token: result["token"] = self.token if self.api_key: @@ -622,6 +633,12 @@ class PlatformConfig: if _typing is None: _typing = extra.get("typing_indicator") + # typing_status_text takes the same two routes (top-level or bridged + # into extra); string passthrough, no coercion. + _typing_text = data.get("typing_status_text") + if _typing_text is None: + _typing_text = data.get("extra", {}).get("typing_status_text") + channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} if isinstance(raw_overrides, dict): @@ -637,6 +654,7 @@ class PlatformConfig: reply_to_mode=data.get("reply_to_mode", "first"), gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), + typing_status_text=_typing_text, channel_overrides=channel_overrides, extra=extra, ) @@ -1392,6 +1410,8 @@ def load_gateway_config() -> GatewayConfig: bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] if "typing_indicator" in platform_cfg: bridged["typing_indicator"] = platform_cfg["typing_indicator"] + if "typing_status_text" in platform_cfg: + bridged["typing_status_text"] = platform_cfg["typing_status_text"] has_channel_overrides = "channel_overrides" in platform_cfg if has_channel_overrides: raw_overrides = platform_cfg.get("channel_overrides") diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 54a601a8d227..2be49ff1742e 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -2678,7 +2678,10 @@ class GoogleChatAdapter(BasePlatformAdapter): thread_id = self._resolve_thread_id( reply_to=None, metadata=metadata, chat_id=chat_id, ) - body: Dict[str, Any] = {"text": "Hermes is thinking…"} + body: Dict[str, Any] = { + "text": getattr(self.config, "typing_status_text", None) + or "Hermes is thinking…" + } if thread_id: body["thread"] = {"name": thread_id} diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index acf653af101b..5d387236e6d2 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -1574,7 +1574,8 @@ class SlackAdapter(BasePlatformAdapter): async def send_typing(self, chat_id: str, metadata=None) -> None: """Show a typing/status indicator using assistant.threads.setStatus. - Displays "is thinking..." next to the bot name in a thread. + Displays "is thinking..." next to the bot name in a thread, or the + platform's ``typing_status_text`` config value when set. Requires the assistant:write or chat:write scope. Auto-clears when the bot sends a reply to the thread. """ @@ -1602,7 +1603,8 @@ class SlackAdapter(BasePlatformAdapter): await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus( channel_id=chat_id, thread_ts=thread_ts, - status="is thinking...", + status=getattr(self.config, "typing_status_text", None) + or "is thinking...", ) except Exception as e: # Silently ignore — may lack assistant:write scope or not be diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 20211fb3cf0f..8def87040dcd 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -94,6 +94,28 @@ class TestPlatformConfigRoundtrip: # extra; from_dict must honor it there too (mirrors _grn fallback). restored = PlatformConfig.from_dict({"extra": {"typing_indicator": False}}) assert restored.typing_indicator is False + + def test_typing_status_text_defaults_none(self): + assert PlatformConfig().typing_status_text is None + assert PlatformConfig.from_dict({}).typing_status_text is None + + def test_typing_status_text_roundtrip(self): + pc = PlatformConfig(enabled=True, typing_status_text="is pouncing… 🐾") + restored = PlatformConfig.from_dict(pc.to_dict()) + assert restored.typing_status_text == "is pouncing… 🐾" + + def test_typing_status_text_resolved_from_extra(self): + # Same bridge route as typing_indicator: the shared-key loop copies a + # nested platforms. value into extra. + restored = PlatformConfig.from_dict( + {"extra": {"typing_status_text": "chasing yarn…"}} + ) + assert restored.typing_status_text == "chasing yarn…" + + def test_typing_status_text_omitted_from_to_dict_when_unset(self): + # None must not serialize — keeps existing config files byte-stable. + assert "typing_status_text" not in PlatformConfig().to_dict() + def test_channel_overrides_roundtrip(self): pc = PlatformConfig( enabled=True, diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 317e984e7e8b..486d720db6d9 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -1314,6 +1314,31 @@ class TestTypingLifecycle: adapter._create_message.assert_awaited_once() assert adapter._typing_messages["spaces/S"] == "spaces/S/messages/THINK" + @pytest.mark.asyncio + async def test_send_typing_uses_configured_status_text(self, adapter): + # typing_status_text replaces the default working-state marker text. + adapter.config.typing_status_text = "is pouncing… 🐾" + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + await adapter.send_typing("spaces/S") + body = adapter._create_message.await_args.args[1] + assert body["text"] == "is pouncing… 🐾" + + @pytest.mark.asyncio + async def test_send_typing_default_status_text(self, adapter): + # Unset config keeps the built-in marker text. + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + await adapter.send_typing("spaces/S") + body = adapter._create_message.await_args.args[1] + assert body["text"] == "Hermes is thinking…" + @pytest.mark.asyncio async def test_send_typing_skips_when_already_tracking(self, adapter): adapter._typing_messages["spaces/S"] = "spaces/S/messages/EXIST" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 8a351dc7fb66..141637e1e288 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -2193,6 +2193,24 @@ class TestSendTyping: status="is thinking...", ) + @pytest.mark.asyncio + async def test_custom_typing_status_text(self): + # typing_status_text overrides the default status wording. + config = PlatformConfig( + enabled=True, token="xoxb-fake-token", + typing_status_text="is pouncing… 🐾", + ) + a = SlackAdapter(config) + a._app = MagicMock() + a._app.client = AsyncMock() + a._app.client.assistant_threads_setStatus = AsyncMock() + await a.send_typing("C123", metadata={"thread_id": "parent_ts"}) + a._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="parent_ts", + status="is pouncing… 🐾", + ) + @pytest.mark.asyncio async def test_noop_without_thread(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock() diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 299b5fa0ad89..c7955d1bb75b 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -94,6 +94,7 @@ These are the most commonly missed scopes. | Scope | Purpose | |-------|---------| | `groups:read` | List and get info about private channels | +| `assistant:write` | Render the working-state status line ("is thinking…") next to the bot name while it processes a message. Without this scope the `assistant.threads.setStatus` call fails silently and Slack shows its own rotating generic placeholders instead ("Finding answers…", "Reviewing findings…", …) — Hermes never controls the text. Required for `typing_status_text` to have any visible effect. | --- @@ -409,6 +410,32 @@ platforms: | `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. | | `platforms.slack.extra.cron_continuable_surface` | `"thread"` | Delivery surface for [continuable cron jobs](../features/cron.md#flat-in-channel-continuation-slack). `"thread"` opens a dedicated thread per delivery (default); `"in_channel"` delivers flat into the channel timeline. Pair `in_channel` with `reply_in_thread: false` (and `require_mention: false`) so a plain channel reply continues the job. | +### Working-State Status Line + +While the agent processes a message, Slack shows a status line next to the bot +name in the thread. By default Hermes sets it to `is thinking...`; customize it +with `typing_status_text` — e.g. a kitten assistant named Ada: + +```yaml +platforms: + slack: + # Custom working-state status line (default: "is thinking..."). + typing_status_text: "is pouncing… 🐾" +``` + +| Key | Default | Description | +|-----|---------|-------------| +| `platforms.slack.typing_status_text` | `"is thinking..."` | Text of the working-state status line shown while the agent processes a message. Requires the `assistant:write` scope — without it the status call fails silently and Slack renders its own generic placeholder, whatever this is set to. Set `typing_indicator: false` to disable the status line entirely. | + +:::note Where the status renders +The custom status appears in the **footer beneath the reply composer** ("*BotName* is thinking…"), not inline in the message list. The inline "Generating response…" / "Finding answers…" lines Slack shows in the message area while an AI app works are **Slack's own rotating indicators** — `assistant.threads.setStatus` does not control those, and both can appear at the same time. +::: + +The same key customizes Google Chat's visible working-state marker message +(`platforms.google_chat.typing_status_text`, default `"Hermes is thinking…"`) — +note that on Google Chat it is a real posted message that gets patched into the +reply, not an ephemeral status. + ### Session Isolation ```yaml