open-swe/tests/slack/test_refresh_slack_status_middleware.py

222 lines
8.2 KiB
Python
Raw Normal View History

feat: add optional Slack Assistants API typing status indicator (#1269) * 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>
2026-05-08 10:21:55 -07:00
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from agent.middleware.refresh_slack_status import (
SlackAssistantStatusMiddleware,
_status_from_recent_tool_calls,
)
from agent.utils.slack import DEFAULT_ASSISTANT_STATUS, DEFAULT_LOADING_MESSAGES
class TestSlackAssistantStatusMiddleware:
def _runtime(self) -> MagicMock:
return MagicMock()
def _config(self) -> dict:
return {"configurable": {"slack_thread": {"channel_id": "C1", "thread_ts": "1.0"}}}
@pytest.mark.asyncio
async def test_before_agent_sets_status_when_slack_thread_present(self) -> None:
middleware = SlackAssistantStatusMiddleware()
with (
patch("agent.middleware.refresh_slack_status.get_config", return_value=self._config()),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
result = await middleware.abefore_agent({"messages": []}, self._runtime())
assert result is None
mock_set.assert_awaited_once()
args = mock_set.await_args.args
kwargs = mock_set.await_args.kwargs
assert args == ("C1", "1.0")
assert kwargs["status"] == DEFAULT_ASSISTANT_STATUS
assert kwargs["loading_messages"] == list(DEFAULT_LOADING_MESSAGES)
@pytest.mark.asyncio
async def test_after_agent_clears_status_when_slack_thread_present(self) -> None:
middleware = SlackAssistantStatusMiddleware()
with (
patch("agent.middleware.refresh_slack_status.get_config", return_value=self._config()),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
result = await middleware.aafter_agent({"messages": []}, self._runtime())
assert result is None
assert mock_set.await_args.kwargs["status"] == ""
assert mock_set.await_args.kwargs["loading_messages"] is None
@pytest.mark.asyncio
async def test_model_call_uses_contextual_status_from_last_tool_call(self) -> None:
middleware = SlackAssistantStatusMiddleware()
ai = AIMessage(
content="",
tool_calls=[{"name": "grep", "args": {"pattern": "foo"}, "id": "tc1"}],
)
request = ModelRequest(
model=MagicMock(),
messages=[],
state={"messages": [ai]},
runtime=self._runtime(),
)
async def handler(_request: ModelRequest) -> ModelResponse:
return ModelResponse(result=[AIMessage(content="done")])
with (
patch("agent.middleware.refresh_slack_status.get_config", return_value=self._config()),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
response = await middleware.awrap_model_call(request, handler)
assert response.result[0].content == "done"
assert mock_set.await_args_list[0].kwargs["status"] == "searching the codebase..."
@pytest.mark.asyncio
async def test_tool_call_uses_tool_specific_status(self) -> None:
middleware = SlackAssistantStatusMiddleware()
request = ToolCallRequest(
tool_call={"name": "execute", "args": {}, "id": "tc1"},
tool=MagicMock(),
state={},
runtime=self._runtime(),
)
async def handler(_request: ToolCallRequest) -> MagicMock:
return MagicMock()
with (
patch("agent.middleware.refresh_slack_status.get_config", return_value=self._config()),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
await middleware.awrap_tool_call(request, handler)
assert mock_set.await_args_list[0].kwargs["status"] == "running commands..."
@pytest.mark.asyncio
async def test_heartbeat_refreshes_while_call_is_running(self) -> None:
middleware = SlackAssistantStatusMiddleware(
heartbeat_interval_seconds=0.01,
max_heartbeat_seconds=10,
)
refreshed = asyncio.Event()
set_count = 0
real_sleep = asyncio.sleep
async def fake_set_status(*_args: object, **_kwargs: object) -> bool:
nonlocal set_count
set_count += 1
if set_count >= 2:
refreshed.set()
return True
async def fake_sleep(_delay: float) -> None:
await real_sleep(0)
request = ModelRequest(
model=MagicMock(),
messages=[],
state={"messages": []},
runtime=self._runtime(),
)
async def handler(_request: ModelRequest) -> ModelResponse:
await refreshed.wait()
return ModelResponse(result=[AIMessage(content="done")])
with (
patch("agent.middleware.refresh_slack_status.get_config", return_value=self._config()),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
side_effect=fake_set_status,
),
patch("agent.middleware.refresh_slack_status.asyncio.sleep", side_effect=fake_sleep),
):
response = await middleware.awrap_model_call(request, handler)
assert response.result[0].content == "done"
assert set_count >= 2
@pytest.mark.asyncio
async def test_skips_when_slack_thread_missing(self) -> None:
middleware = SlackAssistantStatusMiddleware()
with (
patch(
"agent.middleware.refresh_slack_status.get_config",
return_value={"configurable": {}},
),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
result = await middleware.abefore_agent({"messages": []}, self._runtime())
assert result is None
mock_set.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_channel_or_thread_blank(self) -> None:
middleware = SlackAssistantStatusMiddleware()
with (
patch(
"agent.middleware.refresh_slack_status.get_config",
return_value={
"configurable": {"slack_thread": {"channel_id": "", "thread_ts": "1.0"}}
},
),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
result = await middleware.abefore_agent({"messages": []}, self._runtime())
assert result is None
mock_set.assert_not_called()
def test_status_helper_falls_back_when_no_tool_calls(self) -> None:
assert _status_from_recent_tool_calls([]) == DEFAULT_ASSISTANT_STATUS
assert _status_from_recent_tool_calls([AIMessage(content="hi")]) == DEFAULT_ASSISTANT_STATUS
def test_status_helper_falls_back_for_unknown_tool(self) -> None:
ai = AIMessage(
content="",
tool_calls=[{"name": "mystery_tool", "args": {}, "id": "tc1"}],
)
assert _status_from_recent_tool_calls([ai]) == DEFAULT_ASSISTANT_STATUS
@pytest.mark.asyncio
async def test_swallows_config_exceptions(self) -> None:
middleware = SlackAssistantStatusMiddleware()
with (
patch(
"agent.middleware.refresh_slack_status.get_config",
side_effect=RuntimeError("boom"),
),
patch(
"agent.middleware.refresh_slack_status.set_slack_assistant_status",
new_callable=AsyncMock,
) as mock_set,
):
result = await middleware.abefore_agent({"messages": []}, self._runtime())
assert result is None
mock_set.assert_not_called()