From bc74935ec8a5928a6b54cc7861d25bbab0651c22 Mon Sep 17 00:00:00 2001 From: Tanner Date: Tue, 31 Mar 2026 10:24:45 -0400 Subject: [PATCH] feat: manual thread close via slash command (#77) Add CLAW_CLOSE_COMMANDS config with close_commands property, and _close_thread method in SlackPoller that cancels pending tasks, disconnects the client, closes the DB session, and sends a farewell. Close command detection runs in _handle_message before agent dispatch. Closes #77 Co-Authored-By: Claude Sonnet 4.6 --- claw/core/config.py | 5 ++ claw/core/slack_poller.py | 31 ++++++++ openspec/changes/manual-close/spec.md | 28 +++++++ tests/test_slack_poller.py | 106 ++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 openspec/changes/manual-close/spec.md diff --git a/claw/core/config.py b/claw/core/config.py index 69505af..1e97187 100644 --- a/claw/core/config.py +++ b/claw/core/config.py @@ -23,6 +23,11 @@ class AppConfig(BaseSettings): MAX_AGENT_RETRIES: int = 3 ENABLE_PROGRESS_UPDATES: bool = True CLAW_REPLY_HEADER: bool = True + CLAW_CLOSE_COMMANDS: str = "/close,close,close thread,close session,bye,goodbye,done" + + @property + def close_commands(self) -> set[str]: + return {c.strip().lower() for c in self.CLAW_CLOSE_COMMANDS.split(",") if c.strip()} @property def uc_mcp_connections(self) -> list[str]: diff --git a/claw/core/slack_poller.py b/claw/core/slack_poller.py index 98c40d5..a5fc357 100644 --- a/claw/core/slack_poller.py +++ b/claw/core/slack_poller.py @@ -228,6 +228,11 @@ async def _handle_message(self, message: dict) -> None: channel = self._channel_id text = message.get("text", "") + # Check for close command + if text.strip().lower() in self._config.close_commands: + await self._close_thread(thread_ts, channel) + return + try: with DbSession(self._db_engine) as db: # Get or create session @@ -281,6 +286,32 @@ async def _handle_message(self, message: dict) -> None: except Exception: logger.exception("Error handling message ts=%s", ts) + async def _close_thread(self, thread_ts: str, channel: str) -> None: + # Cancel pending response task + task = self._response_tasks.pop(thread_ts, None) + if task is not None: + task.cancel() + # Disconnect client + client = self._clients.pop(thread_ts, None) + if client is not None: + try: + await client.disconnect() + except Exception: + logger.exception("Failed to disconnect client for thread %s", thread_ts) + # Close session in DB + with DbSession(self._db_engine) as db: + close_session(db, thread_ts) + # Send farewell + name = self._config.CLAW_NAME.capitalize() + result = await reply_to_thread( + self._ws, self._config, channel, thread_ts, + f"_{name} session closed. Mention me again to start a new conversation._", + ) + reply_ts = result.get("ts") + if reply_ts: + self._posted_ts.add(reply_ts) + logger.info("Thread %s closed by user command", thread_ts) + async def _evict_lra_client(self) -> None: """Evict the least-recently-active client to free resources.""" if not self._clients: diff --git a/openspec/changes/manual-close/spec.md b/openspec/changes/manual-close/spec.md new file mode 100644 index 0000000..de86714 --- /dev/null +++ b/openspec/changes/manual-close/spec.md @@ -0,0 +1,28 @@ +# OpenSpec: Manual Thread Close via Slash Command + +## Summary +Allow users to close an active Claw session by typing a close command (e.g. `/close`, `bye`, `done`). +When triggered, the poller cancels any pending response task, disconnects the agent client, +closes the DB session, and posts a farewell message. + +## Motivation +Users currently have no way to explicitly end a conversation thread. Sessions only expire +via the 7-day inactivity eviction. A manual close command gives users immediate control. + +## Changes + +### 1. `claw/core/config.py` +- Add `CLAW_CLOSE_COMMANDS: str` setting with default close phrases. +- Add `close_commands` property that parses the CSV into a `set[str]`. + +### 2. `claw/core/slack_poller.py` +- In `_handle_message`, check incoming text against `close_commands` BEFORE client lookup/agent dispatch. +- If matched, call new `_close_thread` method and return early (no agent run). +- `_close_thread`: cancel task, disconnect client, close DB session, send farewell. + +## Test Plan (tests/test_slack_poller.py) +1. `test_close_command_closes_session` — `/close` triggers `close_session` +2. `test_close_command_disconnects_client` — client `.disconnect()` called +3. `test_close_command_sends_farewell` — `reply_to_thread` called with farewell text +4. `test_close_command_does_not_run_agent` — `create_client`/`send_message` NOT called +5. `test_non_close_message_runs_agent` — normal message still dispatches agent diff --git a/tests/test_slack_poller.py b/tests/test_slack_poller.py index f47bb86..2c2c5c1 100644 --- a/tests/test_slack_poller.py +++ b/tests/test_slack_poller.py @@ -2055,3 +2055,109 @@ async def _aiter(): reply_text = mock_reply.call_args[1].get("text") or mock_reply.call_args[0][4] assert reply_text == "hello world" assert not reply_text.startswith("*Claw:*") + + +# --------------------------------------------------------------------------- +# Manual close command — issue #77 +# --------------------------------------------------------------------------- + +class TestManualClose: + """Tests for the /close slash-command and close synonyms.""" + + def _make_poller(self): + ws = _make_ws({"ok": True, "messages": []}) + cfg = _make_config() + cfg.close_commands = {"/close", "close", "close thread", "close session", "bye", "goodbye", "done"} + cfg.CLAW_NAME = "claw" + cfg.ENABLE_PROGRESS_UPDATES = False + cfg.CLAW_REPLY_HEADER = False + poller = SlackPoller(ws, cfg, _make_db_engine()) + poller._channel_id = "C123" + poller._user_id = "U_BOT" + return poller + + @pytest.mark.asyncio + async def test_close_command_closes_session(self): + """'/close' triggers close_session in DB.""" + poller = self._make_poller() + + with patch("claw.core.slack_poller.DbSession"), \ + patch("claw.core.slack_poller.close_session") as mock_close, \ + patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}): + await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001")) + + mock_close.assert_called_once() + # The second arg should be the thread_ts + assert mock_close.call_args[0][1] == "200.001" + + @pytest.mark.asyncio + async def test_close_command_disconnects_client(self): + """Close command disconnects an existing agent client.""" + poller = self._make_poller() + mock_client = AsyncMock() + poller._clients["200.001"] = mock_client + + with patch("claw.core.slack_poller.DbSession"), \ + patch("claw.core.slack_poller.close_session"), \ + patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}): + await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001")) + + mock_client.disconnect.assert_awaited_once() + assert "200.001" not in poller._clients + + @pytest.mark.asyncio + async def test_close_command_sends_farewell(self): + """Close command posts a farewell message in the thread.""" + poller = self._make_poller() + + with patch("claw.core.slack_poller.DbSession"), \ + patch("claw.core.slack_poller.close_session"), \ + patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}) as mock_reply: + await poller._handle_message(_msg("bye", ts="200.001", thread_ts="200.001")) + + mock_reply.assert_called_once() + farewell_text = mock_reply.call_args[0][4] if len(mock_reply.call_args[0]) > 4 else mock_reply.call_args[1].get("text", "") + assert "session closed" in farewell_text.lower() + + @pytest.mark.asyncio + async def test_close_command_does_not_run_agent(self): + """Close command should NOT create a client or send a message to the agent.""" + poller = self._make_poller() + + with patch("claw.core.slack_poller.DbSession"), \ + patch("claw.core.slack_poller.close_session"), \ + patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}), \ + patch("claw.core.slack_poller.create_client", new_callable=AsyncMock) as mock_create, \ + patch("claw.core.slack_poller.send_message", new_callable=AsyncMock) as mock_send: + await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001")) + + mock_create.assert_not_called() + mock_send.assert_not_called() + + @pytest.mark.asyncio + async def test_non_close_message_runs_agent(self): + """A normal message should still dispatch the agent (not short-circuit).""" + poller = self._make_poller() + + mock_client = AsyncMock() + mock_iter = MagicMock() + mock_iter.__aiter__ = MagicMock(return_value=AsyncMock(__anext__=AsyncMock(side_effect=StopAsyncIteration))) + mock_iter.is_error = False + mock_iter.stop_reason = "end_turn" + mock_iter.session_id = "sdk-123" + mock_iter.progress_event_received = False + mock_iter.num_turns = 1 + mock_iter.total_cost_usd = 0.001 + mock_iter.duration_ms = 100 + + with patch("claw.core.slack_poller.DbSession"), \ + patch("claw.core.slack_poller.get_session", return_value=None), \ + patch("claw.core.slack_poller.upsert_session"), \ + patch("claw.core.slack_poller.build_mcp_servers", return_value=[]), \ + patch("claw.core.slack_poller.create_client", new_callable=AsyncMock, return_value=mock_client) as mock_create, \ + patch("claw.core.slack_poller.send_message", new_callable=AsyncMock) as mock_send, \ + patch("claw.core.slack_poller.get_recent_messages", return_value=[]): + await poller._handle_message(_msg("hello there", ts="200.001", thread_ts="200.001")) + + mock_create.assert_called_once() + mock_send.assert_called_once()