feat(gateway): make the working-state status text configurable

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.
This commit is contained in:
George Drury 2026-07-09 18:10:24 +01:00 committed by Teknium
parent 5b44b65887
commit dc0c778b22
7 changed files with 120 additions and 3 deletions

View file

@ -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")

View file

@ -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}

View file

@ -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

View file

@ -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.<plat> 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,

View file

@ -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"

View file

@ -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()

View file

@ -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