mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 10:53:14 +00:00
* feat: add optional Slack Assistants API typing status indicator
Mirrors OpenClaw's pragmatic approach: instead of rebuilding around
assistant_thread_started events, just opt into assistants.threads.setStatus
to show 'is thinking…' while the agent is working, and clear it when
post_slack_thread_reply lands. Gated behind SLACK_ASSISTANTS_API_ENABLED so
it can be toggled without touching code.
* fix(slack): drop redundant clear, add status heartbeat across model calls
- Slack auto-clears the typing indicator on bot post; remove the explicit
assistants.threads.setStatus("") call from post_slack_thread_reply.
- The indicator expires after ~2 minutes; add a before_model middleware
that refreshes it on every model tick so it stays visible across long
agent runs. Reuses the existing slack_thread.{channel_id,thread_ts}
configurable already plumbed for notify_step_limit.
- chat:write is sufficient on the bot token (assistant:write is on the
way out per Slack docs); no scope or app-config change required.
* feat(slack): contextual status text + rotating loading_messages
- set_slack_assistant_status now accepts an optional loading_messages list
(capped at 10 per Slack's API), surfaced via the assistants.threads.setStatus
payload so Slack rotates through them client-side.
- The heartbeat middleware derives a contextual status from the last
assistant message's tool calls (e.g. "searching the codebase…" after
grep, "running commands…" after execute), falling back to the default
"is thinking…" when no tool calls or unknown tool name.
- Adds a curated DEFAULT_LOADING_MESSAGES list passed alongside the
contextual status on each refresh.
* fix slack assistant status lifecycle
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
176 lines
6.3 KiB
Python
176 lines
6.3 KiB
Python
"""Tests for the Slack Assistants API integration (feature-flagged)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from agent.utils import slack as slack_utils
|
|
|
|
|
|
def _ok_response() -> MagicMock:
|
|
response = MagicMock()
|
|
response.json.return_value = {"ok": True}
|
|
response.raise_for_status.return_value = None
|
|
return response
|
|
|
|
|
|
def _err_response(error: str = "channel_not_found") -> MagicMock:
|
|
response = MagicMock()
|
|
response.json.return_value = {"ok": False, "error": error}
|
|
response.raise_for_status.return_value = None
|
|
return response
|
|
|
|
|
|
def _async_client_cm(post_response: MagicMock) -> AsyncMock:
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=post_response)
|
|
return client_cm
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_noop_when_flag_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("SLACK_ASSISTANTS_API_ENABLED", raising=False)
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
ok = await slack_utils.set_slack_assistant_status("C1", "1.0", "thinking…")
|
|
|
|
assert ok is False
|
|
client_cm.post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_noop_when_no_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
ok = await slack_utils.set_slack_assistant_status("C1", "1.0", "thinking…")
|
|
|
|
assert ok is False
|
|
client_cm.post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_calls_correct_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
ok = await slack_utils.set_slack_assistant_status("C1", "1.0", "thinking…")
|
|
|
|
assert ok is True
|
|
client_cm.post.assert_awaited_once()
|
|
args, kwargs = client_cm.post.call_args
|
|
assert args[0].endswith("/assistants.threads.setStatus")
|
|
assert kwargs["json"] == {
|
|
"channel_id": "C1",
|
|
"thread_ts": "1.0",
|
|
"status": "thinking…",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_passes_loading_messages(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
await slack_utils.set_slack_assistant_status(
|
|
"C1", "1.0", "thinking…", loading_messages=["a", "b", "c"]
|
|
)
|
|
|
|
_, kwargs = client_cm.post.call_args
|
|
assert kwargs["json"]["loading_messages"] == ["a", "b", "c"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_caps_loading_messages_at_10(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
await slack_utils.set_slack_assistant_status(
|
|
"C1", "1.0", loading_messages=[f"m{i}" for i in range(15)]
|
|
)
|
|
|
|
_, kwargs = client_cm.post.call_args
|
|
assert len(kwargs["json"]["loading_messages"]) == 10
|
|
assert kwargs["json"]["loading_messages"][0] == "m0"
|
|
assert kwargs["json"]["loading_messages"][-1] == "m9"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_omits_loading_messages_when_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
await slack_utils.set_slack_assistant_status("C1", "1.0", "thinking…")
|
|
|
|
_, kwargs = client_cm.post.call_args
|
|
assert "loading_messages" not in kwargs["json"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_slack_assistant_status_returns_false_on_slack_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_err_response("invalid_thread"))
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
ok = await slack_utils.set_slack_assistant_status("C1", "1.0", "thinking…")
|
|
|
|
assert ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_slack_thread_reply_does_not_call_set_status(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Slack auto-clears the indicator on post; no extra setStatus call needed."""
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", "true")
|
|
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
|
|
|
client_cm = _async_client_cm(_ok_response())
|
|
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
|
ok = await slack_utils.post_slack_thread_reply("C1", "1.0", "hello")
|
|
|
|
assert ok is True
|
|
assert client_cm.post.await_count == 1
|
|
assert client_cm.post.call_args.args[0].endswith("/chat.postMessage")
|
|
|
|
|
|
def test_is_slack_assistants_api_enabled_truthy_values(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
for truthy in ("1", "true", "TRUE", "yes", "Yes"):
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", truthy)
|
|
assert slack_utils._is_slack_assistants_api_enabled() is True
|
|
|
|
for falsy in ("0", "false", "no", "", "off"):
|
|
monkeypatch.setenv("SLACK_ASSISTANTS_API_ENABLED", falsy)
|
|
assert slack_utils._is_slack_assistants_api_enabled() is False
|