mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 22:03: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>
221 lines
8.2 KiB
Python
221 lines
8.2 KiB
Python
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()
|