Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## 0.21.4 — 2026-09-18

### Fixed

- Restore Slack working-state indicators by sending Agent Sessions lifecycle values and falling back to the legacy Assistant status API when Slack rejects the newer endpoint.
4 changes: 2 additions & 2 deletions hermes_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import os
import sys

__version__ = "0.21.3"
__release_date__ = "2026.9.14"
__version__ = "0.21.4"
__release_date__ = "2026.9.18"


def _ensure_utf8():
Expand Down
68 changes: 57 additions & 11 deletions plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,20 @@ def _sdk_supports_agent_sessions() -> bool:
return _AGENT_SESSIONS_SUPPORTED


def _session_status_method(client: Any):
"""Return the status setter: Agent Sessions API when available, else legacy."""
if _sdk_supports_agent_sessions():
method = getattr(client, "agents_sessions_setStatus", None)
if method is not None:
return method
return client.assistant_threads_setStatus
_AGENT_SESSIONS_REJECTION_ERRORS = frozenset({
"invalid_arguments",
"invalid_status",
"method_not_supported",
"not_allowed",
"unknown_method",
})


def _agent_sessions_rejection(exc: BaseException) -> Optional[str]:
"""Return Slack's code when the Agent Sessions status endpoint rejects this install."""
payload = _slack_response_payload(getattr(exc, "response", None))
error = payload.get("error")
return error if isinstance(error, str) and error in _AGENT_SESSIONS_REJECTION_ERRORS else None


def _session_title_method(client: Any):
Expand Down Expand Up @@ -1111,6 +1118,10 @@ def __init__(self, config: PlatformConfig):
# Set once startStream reports the app lacks streaming (Agents & AI Apps
# off / missing scope); later responses skip straight to edit-based streaming.
self._native_stream_unsupported = False
# A server-side Agent Sessions rejection is stable per workspace. Remember it so
# later status refreshes use the legacy free-text API without repeating the failure,
# without degrading another workspace served by the same adapter.
self._agent_sessions_status_degraded: set[str] = set()
# Socket Mode self-healing state for silently dropped websockets; the monotonic
# start time is the grace window for the first ping/pong.
self._app_token: Optional[str] = None
Expand Down Expand Up @@ -2615,12 +2626,47 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:

async def _set_thread_status(
self, chat_id: str, team_id: str, thread_ts: str, status: str, fail_label: str) -> None:
"""``assistant.threads.setStatus`` (empty ``status`` clears); failures are debug-logged."""
"""Set status using the Agent Sessions lifecycle contract, with legacy fallback.

Agent Sessions accepts lifecycle enums rather than display prose. A rejected
Agent Sessions endpoint is retried through the legacy free-text API and remembered
for this adapter process; transient transport failures remain nonfatal without a
duplicate write.
"""
api_method = "assistant.threads.setStatus"
try:
_set_status = _session_status_method(self._get_client(chat_id, team_id=team_id))
await _set_status(channel_id=chat_id, thread_ts=thread_ts, status=status)
client = self._get_client(chat_id, team_id=team_id)
workspace_key = str(team_id or "")
if (workspace_key not in self._agent_sessions_status_degraded
and _sdk_supports_agent_sessions()):
agent_status = getattr(client, "agents_sessions_setStatus", None)
if agent_status is not None:
api_method = "agents.sessions.setStatus"
try:
await agent_status(
channel_id=chat_id,
thread_ts=thread_ts,
status="processing" if status else "active",
)
return
except Exception as exc:
rejected = _agent_sessions_rejection(exc)
if rejected is None:
raise
self._agent_sessions_status_degraded.add(workspace_key)
logger.warning(
"[Slack] %s rejected (%s); falling back to "
"assistant.threads.setStatus %s",
api_method,
rejected,
fail_label,
)
api_method = "assistant.threads.setStatus"
await client.assistant_threads_setStatus(
channel_id=chat_id, thread_ts=thread_ts, status=status)
except Exception as e:
logger.debug("[Slack] assistant.threads.setStatus %s: %s", fail_label, e)
# Slack SDK exception payloads may contain request content; log only the type.
logger.debug("[Slack] %s %s (%s)", api_method, fail_label, type(e).__name__)

@staticmethod
def _default_status_text(started: Optional[float]) -> str:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[project]
name = "hermes-agent"
version = "0.21.3"
version = "0.21.4"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
Expand Down
40 changes: 38 additions & 2 deletions tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -6102,7 +6102,7 @@ async def test_typing_uses_agent_sessions_when_supported(self):
a._app.client.agents_sessions_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="parent_ts",
status="is thinking...",
status="processing",
)
a._app.client.assistant_threads_setStatus.assert_not_called()

Expand Down Expand Up @@ -6130,10 +6130,46 @@ async def test_stop_typing_clears_via_agent_sessions(self):
a._app.client.agents_sessions_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="parent_ts",
status="",
status="active",
)
a._app.client.assistant_threads_setStatus.assert_not_called()

@pytest.mark.asyncio
async def test_agent_sessions_rejection_degrades_to_legacy_for_set_and_clear(self):
class RejectedStatus(Exception):
def __init__(self):
super().__init__("Slack API rejected status")
self.response = {"ok": False, "error": "invalid_arguments"}

_slack_mod._AGENT_SESSIONS_SUPPORTED = True
a = self._adapter()
a._app.client.agents_sessions_setStatus = AsyncMock(side_effect=RejectedStatus())
a._app.client.assistant_threads_setStatus = AsyncMock()

await a.send_typing("C123", metadata={"thread_id": "parent_ts"})
await a.stop_typing("C123", metadata={"thread_id": "parent_ts"})

a._app.client.agents_sessions_setStatus.assert_awaited_once_with(
channel_id="C123", thread_ts="parent_ts", status="processing")
assert a._app.client.assistant_threads_setStatus.await_args_list == [
call(channel_id="C123", thread_ts="parent_ts", status="is thinking..."),
call(channel_id="C123", thread_ts="parent_ts", status=""),
]

@pytest.mark.asyncio
async def test_agent_sessions_transport_failure_does_not_retry_legacy(self):
_slack_mod._AGENT_SESSIONS_SUPPORTED = True
a = self._adapter()
a._app.client.agents_sessions_setStatus = AsyncMock(
side_effect=RuntimeError("temporary transport failure"))
a._app.client.assistant_threads_setStatus = AsyncMock()

await a.send_typing("C123", metadata={"thread_id": "parent_ts"})

a._app.client.agents_sessions_setStatus.assert_awaited_once()
a._app.client.assistant_threads_setStatus.assert_not_awaited()
assert not a._agent_sessions_status_degraded

@pytest.mark.asyncio
async def test_thread_title_uses_agents_sessions_rename(self):
_slack_mod._AGENT_SESSIONS_SUPPORTED = True
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions website/docs/user-guide/messaging/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,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 status call (`agents.sessions.setStatus` on slack-sdk 3.44+, `assistant.threads.setStatus` on older SDKs) 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. |
| `assistant:write` | Render the working-state status line next to the bot name while it processes a message. On slack-sdk 3.44+, Hermes uses the Agent Sessions lifecycle states (`processing` / `active`); if Slack rejects that endpoint, Hermes falls back to `assistant.threads.setStatus`, which supports the configured free text. Without this scope both calls can fail and Slack may show only its own generic placeholders. |

---

Expand Down Expand Up @@ -489,8 +489,9 @@ their own reply policies can still create loops.
### 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:
name in the thread. On Slack's legacy Assistant API, Hermes sets it to
`is thinking...`; customize that legacy text with `typing_status_text` — e.g. a
kitten assistant named Ada:

```yaml
platforms:
Expand All @@ -501,10 +502,10 @@ platforms:

| 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. |
| `platforms.slack.typing_status_text` | `"is thinking..."` | Text sent through the legacy Assistant status API. The Agent Sessions API accepts lifecycle enums only, so it renders Slack's generic processing state; Hermes automatically returns to the configured text if Slack rejects that endpoint. Requires `assistant:write`. 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** — the status API (`agents.sessions.setStatus` / `assistant.threads.setStatus`) does not control those, and both can appear at the same time.
The working state appears in the **footer beneath the reply composer**, not inline in the message list. Legacy Assistant status can show the configured text ("*BotName* is thinking…"); Agent Sessions renders Slack's lifecycle UI. The inline "Generating response…" / "Finding answers…" lines Slack shows in the message area are **Slack's own rotating indicators**, and both surfaces can appear at the same time.
:::

The same key customizes Google Chat's visible working-state marker message
Expand Down