mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
5b44b65887
commit
dc0c778b22
7 changed files with 120 additions and 3 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue